From 0a5840a255690d850721d05552db3b172448a96f Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 25 Feb 2026 20:06:20 +0100 Subject: [PATCH] feat: app-to-app integration framework + OnlyOffice handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic integration system for connecting deployed apps via toggle UI. First handlers: OnlyOffice→FileBrowser (config.yaml patch) and OnlyOffice→Nextcloud (occ CLI). Lifecycle hooks auto-suspend on stop and re-apply on start. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 13 ++ controller/README.md | 80 ++++++- controller/cmd/controller/main.go | 24 +++ controller/internal/api/router.go | 95 +++++++++ .../internal/integrations/integrations.go | 57 +++++ controller/internal/integrations/lifecycle.go | 148 +++++++++++++ controller/internal/integrations/manager.go | 198 ++++++++++++++++++ .../integrations/onlyoffice_filebrowser.go | 113 ++++++++++ .../integrations/onlyoffice_nextcloud.go | 93 ++++++++ controller/internal/settings/settings.go | 73 +++++++ controller/internal/stacks/manager.go | 6 + controller/internal/stacks/metadata.go | 13 ++ controller/internal/web/handlers.go | 10 + controller/internal/web/server.go | 9 + .../internal/web/templates/app_info.html | 61 ++++++ 15 files changed, 992 insertions(+), 1 deletion(-) create mode 100644 controller/internal/integrations/integrations.go create mode 100644 controller/internal/integrations/lifecycle.go create mode 100644 controller/internal/integrations/manager.go create mode 100644 controller/internal/integrations/onlyoffice_filebrowser.go create mode 100644 controller/internal/integrations/onlyoffice_nextcloud.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 254c2f8..9e9b5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ ## Changelog +### v0.31.0 — App-to-App Integration Framework (2026-02-25) + +#### Added +- **Generic integration framework** (`internal/integrations/`) — Extensible system for connecting deployed apps to each other via toggle switches on the provider's app info page +- **OnlyOffice → FileBrowser integration** — Toggle enables document editing in FileBrowser by patching `config.yaml` with OnlyOffice URL and JWT secret +- **OnlyOffice → Nextcloud integration** — Toggle installs and configures the OnlyOffice connector app via `occ` CLI commands +- **Integration lifecycle hooks** — Integrations auto-suspend when provider or target stops, auto-re-enable when both are running again, permanently removed on app deletion +- **Integration API endpoints** — `GET /api/integrations/{provider}` (list), `POST /api/integrations/{provider}/{target}` (toggle) +- **Integration UI** — "Integrációk" section on app info page with toggle switches, status badges, and target availability indicators +- **`IntegrationDef`** in `.felhom.yml` metadata — Apps can declare integrations with target app slug, label, and description +- **`IntegrationState`** in `settings.json` — Persistent integration state with enabled/status/error tracking +- **SyncFileBrowserMounts re-apply** — After config regeneration (which overwrites config.yaml), active integrations are automatically re-applied + ### v0.30.7 — Monitoring: Fix Memory Legend Overflow (2026-02-25) #### Fixed diff --git a/controller/README.md b/controller/README.md index 9db58cb..fb6ba52 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1197,11 +1197,76 @@ All mutating endpoints trigger an async Cloudflare sync. The `/api/geo/` path ac --- +### 14. App-to-App Integrations + +Generic framework for connecting deployed applications to each other. Provider apps declare available integrations in `.felhom.yml`, and users enable/disable them via toggle switches on the provider's app info page. + +#### Architecture (`internal/integrations/`) + +- **`integrations.go`** — Core types: `Handler` interface (`Apply`/`Revoke`), `ApplyContext` (carries domain, decrypted env vars, restart func), `StatusInfo` (UI data) +- **`manager.go`** — `Manager` coordinates toggle operations, builds apply contexts, lists integrations for UI. Uses `StackProvider` interface to break circular imports (adapted in main.go) +- **`lifecycle.go`** — `OnStackStop` (suspend active integrations), `OnStackStart` (re-apply enabled integrations), `OnStackRemove` (permanent cleanup) +- **Handler implementations** — One file per integration pair (e.g. `onlyoffice_filebrowser.go`, `onlyoffice_nextcloud.go`) + +#### Integration State + +Stored in `settings.json` under `integrations` map (key: `"provider:target"`): +- `enabled` — User intent (survives stop/restart) +- `status` — Current state: `"active"`, `"error"`, `"disabled"`, `"provider_stopped"`, `"target_unavailable"` +- `last_error` — Most recent error message +- `enabled_at` — RFC3339 timestamp + +#### Lifecycle + +1. **Enable**: User toggles on → validates both apps deployed+running → calls `Handler.Apply()` → persists state as `"active"` +2. **Provider/target stops**: `OnStackStop` → calls `Handler.Revoke()` → sets status to `"provider_stopped"` or `"target_unavailable"` (keeps `enabled=true`) +3. **Provider/target starts**: `OnStackStart` → finds enabled integrations with non-active status → re-applies if both sides running +4. **Provider/target removed**: `OnStackRemove` → revokes and deletes integration state permanently +5. **FileBrowser config regen**: `SyncFileBrowserMounts` overwrites `config.yaml` → `OnStackStart("filebrowser")` re-applies active integrations + +#### Built-in Handlers + +**OnlyOffice → FileBrowser** (`onlyoffice_filebrowser.go`): +- Apply: Reads JWT_SECRET + SUBDOMAIN from OnlyOffice app.yaml, appends `integrations.office` block to FileBrowser `config.yaml`, restarts FileBrowser +- Revoke: Strips `integrations:` block from config.yaml, restarts FileBrowser + +**OnlyOffice → Nextcloud** (`onlyoffice_nextcloud.go`): +- Apply: Runs `docker exec -u www-data nextcloud php occ` commands (app:install, app:enable, config:app:set for DocumentServerUrl, jwt_secret) +- Revoke: Runs `occ app:disable onlyoffice` + +#### Metadata (`.felhom.yml`) + +```yaml +integrations: + - target: filebrowser + label: "FileBrowser integráció" + description: "Dokumentumok szerkesztése a fájlkezelőben" + - target: nextcloud + label: "Nextcloud integráció" + description: "Dokumentumok szerkesztése a Nextcloudban" +``` + +#### API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/integrations/{provider}` | List integrations for a provider app | +| POST | `/api/integrations/{provider}/{target}` | Enable/disable integration (`{"enable": true/false}`) | + +#### UI + +Toggle switches on the provider's app info page ("Integrációk" section). Each integration shows: +- Label and description +- Status badge: "Aktív", "Nincs telepítve", "Célalkalmazás leállítva", "Hiba" +- Toggle checkbox (disabled when target not deployed/running) + +--- + ## Repository Layout ``` controller/ -├── cmd/controller/main.go # Entry point, wires all 16 modules (setup mode branch + normal startup) +├── cmd/controller/main.go # Entry point, wires all 17 modules (setup mode branch + normal startup) ├── internal/ │ ├── config/config.go # YAML loader, validation, env overrides │ ├── crypto/crypto.go # AES-256-GCM encryption for app.yaml secrets, key management @@ -1237,6 +1302,12 @@ controller/ │ │ ├── waf.go # WAF rule CRUD + expression builders │ │ ├── countries.go # ISO 3166-1 country codes + Hungarian names │ │ └── geosync.go # Geo sync orchestrator (diff & apply rules) +│ ├── integrations/ +│ │ ├── integrations.go # Core types: Handler interface, ApplyContext, StatusInfo +│ │ ├── manager.go # Manager: Toggle, ListForProvider, StackProvider interface +│ │ ├── lifecycle.go # OnStackStop, OnStackStart, OnStackRemove hooks +│ │ ├── onlyoffice_filebrowser.go # OnlyOffice → FileBrowser handler (config.yaml patch) +│ │ └── onlyoffice_nextcloud.go # OnlyOffice → Nextcloud handler (occ commands) │ ├── assets/syncer.go # Hub asset sync (download, SHA-256 compare, resolve) │ ├── api/ │ │ ├── router.go # REST API endpoints (~36 routes) @@ -1484,6 +1555,13 @@ Config endpoints accept session auth OR `Authorization: Bearer ` (s | POST | `/api/assets/sync` | Trigger on-demand asset sync from Hub (async) | | GET | `/api/assets/status` | Asset sync status (last sync, file count, total bytes) | +### Integrations + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/integrations/{provider}` | List integrations for provider app (status, target availability) | +| POST | `/api/integrations/{provider}/{target}` | Enable/disable integration (`{"enable": true/false}`) | + ### Debug (debug mode only) | Method | Endpoint | Description | diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 97d2ade..89b68ee 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -23,6 +23,7 @@ import ( cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare" "gitea.dooplex.hu/admin/felhom-controller/internal/crypto" "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/integrations" "gitea.dooplex.hu/admin/felhom-controller/internal/metrics" "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" "gitea.dooplex.hu/admin/felhom-controller/internal/notify" @@ -647,9 +648,15 @@ func main() { logger.Printf("[INFO] Geo-restriction support enabled (CF API token configured)") } + // --- Initialize integration manager --- + integrationStacks := &integrationStackAdapter{mgr: stackMgr} + integrationMgr := integrations.NewManager(sett, integrationStacks, cfg.Customer.Domain, cfg.Paths.StacksDir, encKey, logger) + apiRouter.SetIntegrationManager(integrationMgr) + // --- Initialize web server --- webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version) webServer.SetEncryptionKey(encKey) + webServer.SetIntegrationManager(integrationMgr) webServer.SetStorageWatchdog(storageWatchdog) if assetsSyncer != nil { webServer.SetAssetsSyncer(assetsSyncer) @@ -917,6 +924,23 @@ func (a *stackAdapter) GetStackHDDPath(name string) string { return "" } +// integrationStackAdapter implements integrations.StackProvider using stacks.Manager. +type integrationStackAdapter struct { + mgr *stacks.Manager +} + +func (a *integrationStackAdapter) GetStack(name string) (*stacks.Stack, bool) { + return a.mgr.GetStack(name) +} + +func (a *integrationStackAdapter) GetStacks() []stacks.Stack { + return a.mgr.GetStacks() +} + +func (a *integrationStackAdapter) RestartStack(name string) error { + return a.mgr.RestartStack(name) +} + // geoStackAdapter implements cloudflare.StackLister for geo-restriction sync. type geoStackAdapter struct { mgr *stacks.Manager diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index c1ebcf2..67b9edb 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -14,6 +14,7 @@ import ( "time" "gitea.dooplex.hu/admin/felhom-controller/internal/assets" + "gitea.dooplex.hu/admin/felhom-controller/internal/integrations" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare" "gitea.dooplex.hu/admin/felhom-controller/internal/config" @@ -55,6 +56,9 @@ type Router struct { // Geo-restriction sync manager geoSync *cf.GeoSyncManager + + // App-to-app integration manager (nil if not configured) + integrationMgr *integrations.Manager } // SetAssetsSyncer sets the Hub asset syncer for on-demand sync triggers. @@ -67,6 +71,11 @@ func (r *Router) SetGeoSync(gs *cf.GeoSyncManager) { r.geoSync = gs } +// SetIntegrationManager sets the app-to-app integration manager. +func (r *Router) SetIntegrationManager(im *integrations.Manager) { + r.integrationMgr = im +} + func NewRouter(cfg *config.Config, configPath string, sett *settings.Settings, stackMgr *stacks.Manager, syncer *catalogsync.Syncer, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, metricsStore *metrics.MetricsStore, updater *selfupdate.Updater, notif *notify.Notifier, logger *log.Logger) *Router { return &Router{cfg: cfg, configPath: configPath, sett: sett, stackMgr: stackMgr, syncer: syncer, cpuCollector: cpuCollector, backupMgr: backupMgr, crossDriveRunner: crossDrive, metricsStore: metricsStore, updater: updater, notifier: notif, logger: logger} } @@ -120,6 +129,23 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { case path == "/config" && req.Method == http.MethodGet: r.configContent(w, req) + // --- Integration routes (must be before hasSuffix-based stack cases) --- + + // GET /api/integrations/{provider} — list integrations for a provider + case strings.HasPrefix(path, "/integrations/") && !strings.Contains(strings.TrimPrefix(path, "/integrations/"), "/") && req.Method == http.MethodGet: + provider := strings.TrimPrefix(path, "/integrations/") + r.listIntegrations(w, provider) + + // POST /api/integrations/{provider}/{target} — toggle integration + case strings.HasPrefix(path, "/integrations/") && req.Method == http.MethodPost: + rest := strings.TrimPrefix(path, "/integrations/") + parts := strings.SplitN(rest, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid integration path"}) + return + } + r.toggleIntegration(w, req, parts[0], parts[1]) + // GET /api/stacks/{name}/deploy-fields case hasSuffix(path, "/deploy-fields") && req.Method == http.MethodGet: r.getDeployFields(w, req, extractName(path, "/deploy-fields")) @@ -361,6 +387,11 @@ func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name stri if r.OnGeoRelevantChange != nil { go r.OnGeoRelevantChange() } + + // Re-apply integrations that target this newly deployed stack + if r.integrationMgr != nil { + go r.integrationMgr.OnStackStart(context.Background(), name) + } } func (r *Router) actionStack(w http.ResponseWriter, action, name string) { @@ -419,6 +450,16 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) { } writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"}) + + // Trigger integration lifecycle hooks after successful action + if r.integrationMgr != nil { + switch action { + case "start", "restart": + go r.integrationMgr.OnStackStart(context.Background(), name) + case "stop": + go r.integrationMgr.OnStackStop(context.Background(), name) + } + } } func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request, name string) { @@ -442,6 +483,55 @@ func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request, writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"}) } +// --- Integration API handlers --- + +func (r *Router) listIntegrations(w http.ResponseWriter, provider string) { + if r.integrationMgr == nil { + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: []integrations.StatusInfo{}}) + return + } + list := r.integrationMgr.ListForProvider(provider) + if list == nil { + list = []integrations.StatusInfo{} + } + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: list}) +} + +func (r *Router) toggleIntegration(w http.ResponseWriter, req *http.Request, provider, target string) { + limitBody(w, req) + if r.integrationMgr == nil { + writeJSON(w, http.StatusServiceUnavailable, apiResponse{OK: false, Error: "integrations not available"}) + return + } + + var body struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"}) + return + } + + action := "enable" + if !body.Enabled { + action = "disable" + } + r.logger.Printf("[API] Integration %s requested: %s:%s", action, provider, target) + + state, err := r.integrationMgr.Toggle(req.Context(), provider, target, body.Enabled) + if err != nil { + r.logger.Printf("[API] Integration toggle failed for %s:%s: %v", provider, target, err) + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) + return + } + + msg := "Integráció engedélyezve" + if !body.Enabled { + msg = "Integráció kikapcsolva" + } + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: state, Message: msg}) +} + func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name string) { lines := 100 if v := req.URL.Query().Get("lines"); v != "" { @@ -550,6 +640,11 @@ func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name stri r.notifier.NotifyAppRemoved(name, name) } + // Clean up integrations for removed stack + if r.integrationMgr != nil { + r.integrationMgr.OnStackRemove(context.Background(), name) + } + // Re-sync geo rules (hostname removed) if r.OnGeoRelevantChange != nil { go r.OnGeoRelevantChange() diff --git a/controller/internal/integrations/integrations.go b/controller/internal/integrations/integrations.go new file mode 100644 index 0000000..e03632a --- /dev/null +++ b/controller/internal/integrations/integrations.go @@ -0,0 +1,57 @@ +package integrations + +import ( + "log" + "strings" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// IntegrationKey builds the settings key: "provider:target". +func IntegrationKey(provider, target string) string { + return provider + ":" + target +} + +// ParseIntegrationKey splits "provider:target" into its components. +func ParseIntegrationKey(key string) (provider, target string, ok bool) { + parts := strings.SplitN(key, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + +// ApplyContext holds all context needed by an integration handler. +type ApplyContext struct { + ProviderName string // e.g., "onlyoffice" + TargetName string // e.g., "filebrowser" + Domain string // e.g., "demo-felhom.eu" + ProviderEnv map[string]string // decrypted app.yaml env of provider + ProviderMeta *stacks.Metadata + StacksDir string + Logger *log.Logger + RestartStack func(name string) error // restart via stacks.Manager +} + +// Handler defines the interface for a concrete integration handler. +// Each provider:target pair has one Handler implementation. +type Handler interface { + // Apply enables the integration (writes configs, runs commands, restarts containers). + Apply(ac *ApplyContext) error + // Revoke disables the integration (removes configs, runs commands, restarts containers). + Revoke(ac *ApplyContext) error +} + +// StatusInfo is returned by the API for UI display. +type StatusInfo struct { + Key string `json:"key"` // "onlyoffice:filebrowser" + Provider string `json:"provider"` + Target string `json:"target"` + Label string `json:"label"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + Status string `json:"status"` // "active", "error", "disabled", "provider_stopped", "target_unavailable" + LastError string `json:"last_error,omitempty"` + TargetDeployed bool `json:"target_deployed"` + TargetRunning bool `json:"target_running"` +} diff --git a/controller/internal/integrations/lifecycle.go b/controller/internal/integrations/lifecycle.go new file mode 100644 index 0000000..910bf43 --- /dev/null +++ b/controller/internal/integrations/lifecycle.go @@ -0,0 +1,148 @@ +package integrations + +import ( + "context" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// OnStackStop is called when a stack is stopped. +// Revokes active integrations where this stack is provider or target. +// Keeps enabled=true so OnStackStart can re-apply them later. +func (m *Manager) OnStackStop(_ context.Context, stackName string) { + m.mu.Lock() + defer m.mu.Unlock() + + all := m.sett.GetIntegrationsForProvider(stackName) + targetAll := m.sett.GetIntegrationsForTarget(stackName) + for k, v := range targetAll { + all[k] = v + } + + 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 + } + + ac, err := m.buildApplyContext(provider, target) + if err != nil { + m.logger.Printf("[WARN] Cannot build context for integration %s revoke: %v", key, err) + continue + } + + if err := handler.Revoke(ac); err != nil { + m.logger.Printf("[WARN] Integration revoke on stop failed for %s: %v", key, err) + } + + if provider == stackName { + state.Status = "provider_stopped" + } else { + state.Status = "target_unavailable" + } + _ = m.sett.SetIntegrationState(key, state) + m.logger.Printf("[INFO] Integration %s suspended (stack %s stopped)", key, stackName) + } +} + +// OnStackStart is called when a stack starts. +// Re-applies integrations that were previously enabled but are not currently active. +func (m *Manager) OnStackStart(_ context.Context, stackName string) { + m.mu.Lock() + defer m.mu.Unlock() + + all := m.sett.GetIntegrationsForProvider(stackName) + targetAll := m.sett.GetIntegrationsForTarget(stackName) + for k, v := range targetAll { + all[k] = v + } + + for key, state := range all { + if !state.Enabled || state.Status == "active" { + continue + } + + provider, target, ok := ParseIntegrationKey(key) + if !ok { + continue + } + + // Both provider and target must be running to re-apply + provStack, pOk := m.stacks.GetStack(provider) + if !pOk || !provStack.Deployed || provStack.State != stacks.StateRunning { + continue + } + if target != "filebrowser" { + tgtStack, tOk := m.stacks.GetStack(target) + if !tOk || !tgtStack.Deployed || tgtStack.State != stacks.StateRunning { + continue + } + } + + handler, hOk := m.handlers[key] + if !hOk { + continue + } + + ac, err := m.buildApplyContext(provider, target) + if err != nil { + m.logger.Printf("[WARN] Cannot re-apply integration %s on start: %v", key, err) + continue + } + + if err := handler.Apply(ac); err != nil { + m.logger.Printf("[WARN] Integration re-apply on start failed for %s: %v", key, err) + state.Status = "error" + state.LastError = err.Error() + _ = m.sett.SetIntegrationState(key, state) + continue + } + + state.Status = "active" + state.LastError = "" + _ = m.sett.SetIntegrationState(key, state) + m.logger.Printf("[INFO] Integration %s re-activated (stack %s started)", key, stackName) + } +} + +// OnStackRemove is called when a stack is removed. +// Permanently revokes and deletes integration state. +func (m *Manager) OnStackRemove(_ context.Context, stackName string) { + m.mu.Lock() + defer m.mu.Unlock() + + all := m.sett.GetIntegrationsForProvider(stackName) + targetAll := m.sett.GetIntegrationsForTarget(stackName) + for k, v := range targetAll { + all[k] = v + } + + for key, state := range all { + provider, target, ok := ParseIntegrationKey(key) + if !ok { + continue + } + + if state.Enabled { + handler, hOk := m.handlers[key] + if hOk { + ac, _ := m.buildApplyContext(provider, target) + if ac != nil { + _ = handler.Revoke(ac) + } + } + } + + _ = m.sett.RemoveIntegrationState(key) + m.logger.Printf("[INFO] Integration %s removed (stack %s removed)", key, stackName) + } +} diff --git a/controller/internal/integrations/manager.go b/controller/internal/integrations/manager.go new file mode 100644 index 0000000..348a7bd --- /dev/null +++ b/controller/internal/integrations/manager.go @@ -0,0 +1,198 @@ +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 +} + +// 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{}) + return m +} + +// 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) + 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 !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 !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) + } + } + + if err := handler.Apply(ac); err != nil { + 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) + } + state.Enabled = true + state.Status = "active" + state.EnabledAt = time.Now().UTC().Format(time.RFC3339) + m.logger.Printf("[INFO] Integration %s enabled", key) + } else { + if err := handler.Revoke(ac); err != nil { + m.logger.Printf("[WARN] Integration revoke failed for %s: %v", key, err) + state.LastError = err.Error() + } + state.Enabled = false + state.Status = "disabled" + m.logger.Printf("[INFO] 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 + } + + 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 { + return nil, fmt.Errorf("szolgáltató stack %q nem található", provider) + } + + // Load decrypted env from provider's app.yaml + provEnv := m.loadDecryptedEnv(provStack) + + 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 +} + +// 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 { + return nil + } + if m.encKey != nil { + cfg.Env = crypto.DecryptMap(m.encKey, cfg.Env) + } + return cfg.Env +} diff --git a/controller/internal/integrations/onlyoffice_filebrowser.go b/controller/internal/integrations/onlyoffice_filebrowser.go new file mode 100644 index 0000000..63079e8 --- /dev/null +++ b/controller/internal/integrations/onlyoffice_filebrowser.go @@ -0,0 +1,113 @@ +package integrations + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// OnlyOfficeFileBrowserHandler enables/disables OnlyOffice document editing in FileBrowser Quantum. +type OnlyOfficeFileBrowserHandler struct{} + +func (h *OnlyOfficeFileBrowserHandler) Apply(ac *ApplyContext) error { + jwtSecret := ac.ProviderEnv["JWT_SECRET"] + if jwtSecret == "" { + return fmt.Errorf("OnlyOffice JWT_SECRET nincs beállítva — telepítsd újra az alkalmazást") + } + + subdomain := ac.ProviderEnv["SUBDOMAIN"] + if subdomain == "" && ac.ProviderMeta != nil { + subdomain = ac.ProviderMeta.Subdomain + } + if subdomain == "" { + return fmt.Errorf("OnlyOffice aldomain nem ismert") + } + + configPath := filepath.Join(ac.StacksDir, "filebrowser", "config.yaml") + configData, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("FileBrowser config olvasási hiba: %w", err) + } + + // Remove any existing integrations section, then append the new one + configStr := removeIntegrationsSection(string(configData)) + + officeURL := fmt.Sprintf("https://%s.%s", subdomain, ac.Domain) + internalURL := "http://onlyoffice:80" + + integrationsBlock := fmt.Sprintf("integrations:\n office:\n url: %q\n internalUrl: %q\n secret: %q\n viewOnly: false\n", + officeURL, internalURL, jwtSecret) + + configStr = strings.TrimRight(configStr, "\n") + "\n" + integrationsBlock + + // Atomic write + tmpPath := configPath + ".tmp" + if err := os.WriteFile(tmpPath, []byte(configStr), 0644); err != nil { + return fmt.Errorf("config írási hiba: %w", err) + } + if err := os.Rename(tmpPath, configPath); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("config átnevezési hiba: %w", err) + } + + ac.Logger.Printf("[INFO] FileBrowser config updated with OnlyOffice integration") + return ac.RestartStack("filebrowser") +} + +func (h *OnlyOfficeFileBrowserHandler) Revoke(ac *ApplyContext) error { + configPath := filepath.Join(ac.StacksDir, "filebrowser", "config.yaml") + configData, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("config olvasási hiba: %w", err) + } + + cleaned := removeIntegrationsSection(string(configData)) + if cleaned == string(configData) { + return nil // no integrations section, nothing to remove + } + + tmpPath := configPath + ".tmp" + if err := os.WriteFile(tmpPath, []byte(cleaned), 0644); err != nil { + return fmt.Errorf("config írási hiba: %w", err) + } + if err := os.Rename(tmpPath, configPath); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("config átnevezési hiba: %w", err) + } + + ac.Logger.Printf("[INFO] FileBrowser config cleaned — OnlyOffice integration removed") + return ac.RestartStack("filebrowser") +} + +// removeIntegrationsSection strips the integrations: YAML block from a config string. +// It removes from the line starting with "integrations:" to the next unindented key or EOF. +func removeIntegrationsSection(config string) string { + lines := strings.Split(config, "\n") + var result []string + inBlock := false + for _, line := range lines { + if strings.HasPrefix(line, "integrations:") { + inBlock = true + continue + } + if inBlock { + trimmed := strings.TrimRight(line, " \t\r") + if trimmed == "" { + continue // skip blank lines within block + } + if line[0] != ' ' && line[0] != '\t' { + // New top-level key — end of integrations block + inBlock = false + result = append(result, line) + } + // else: still inside indented block, skip + continue + } + result = append(result, line) + } + return strings.Join(result, "\n") +} diff --git a/controller/internal/integrations/onlyoffice_nextcloud.go b/controller/internal/integrations/onlyoffice_nextcloud.go new file mode 100644 index 0000000..98cd312 --- /dev/null +++ b/controller/internal/integrations/onlyoffice_nextcloud.go @@ -0,0 +1,93 @@ +package integrations + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" +) + +// OnlyOfficeNextcloudHandler enables/disables OnlyOffice document editing in Nextcloud via occ. +type OnlyOfficeNextcloudHandler struct{} + +func (h *OnlyOfficeNextcloudHandler) Apply(ac *ApplyContext) error { + jwtSecret := ac.ProviderEnv["JWT_SECRET"] + if jwtSecret == "" { + return fmt.Errorf("OnlyOffice JWT_SECRET nincs beállítva") + } + + subdomain := ac.ProviderEnv["SUBDOMAIN"] + if subdomain == "" && ac.ProviderMeta != nil { + subdomain = ac.ProviderMeta.Subdomain + } + if subdomain == "" { + return fmt.Errorf("OnlyOffice aldomain nem ismert") + } + + publicURL := fmt.Sprintf("https://%s.%s", subdomain, ac.Domain) + internalURL := "http://onlyoffice:80" + + // Install and configure OnlyOffice app in Nextcloud + commands := []struct { + args []string + tolerate string // substring in output to tolerate as success + }{ + { + args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:install", "onlyoffice"}, + tolerate: "already installed", + }, + { + args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:enable", "onlyoffice"}, + }, + { + args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "DocumentServerUrl", "--value=" + publicURL}, + }, + { + args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "DocumentServerInternalUrl", "--value=" + internalURL}, + }, + { + args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "jwt_secret", "--value=" + jwtSecret}, + }, + } + + for _, cmd := range commands { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + c := exec.CommandContext(ctx, cmd.args[0], cmd.args[1:]...) + out, err := c.CombinedOutput() + cancel() + if err != nil { + if cmd.tolerate != "" && strings.Contains(string(out), cmd.tolerate) { + ac.Logger.Printf("[DEBUG] Nextcloud occ: tolerated — %s", strings.TrimSpace(string(out))) + continue + } + return fmt.Errorf("occ parancs sikertelen (%s): %v (kimenet: %s)", cmd.args[len(cmd.args)-1], err, strings.TrimSpace(string(out))) + } + ac.Logger.Printf("[DEBUG] Nextcloud occ %s: ok", strings.Join(cmd.args[7:], " ")) + } + + ac.Logger.Printf("[INFO] OnlyOffice integration applied to Nextcloud") + return nil +} + +func (h *OnlyOfficeNextcloudHandler) Revoke(ac *ApplyContext) error { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:disable", "onlyoffice") + out, err := cmd.CombinedOutput() + if err != nil { + outStr := string(out) + // Tolerate container not running or app not enabled + if strings.Contains(err.Error(), "No such container") || + strings.Contains(outStr, "not enabled") || + strings.Contains(outStr, "not installed") { + ac.Logger.Printf("[DEBUG] Nextcloud occ app:disable skipped — %s", strings.TrimSpace(outStr)) + return nil + } + return fmt.Errorf("occ app:disable sikertelen: %v (kimenet: %s)", err, strings.TrimSpace(outStr)) + } + + ac.Logger.Printf("[INFO] OnlyOffice integration revoked from Nextcloud") + return nil +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 8ebef22..36d392a 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -49,6 +49,17 @@ type Settings struct { // 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. @@ -924,3 +935,65 @@ func (s *Settings) SetGeoSyncState(zoneID, rulesetID, syncError string) error { } 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.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 +} diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index c52b54b..beb3620 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -535,6 +535,12 @@ func deepCopyStack(s *Stack) Stack { } } + // Deep-copy Meta.Integrations + if s.Meta.Integrations != nil { + cp.Meta.Integrations = make([]IntegrationDef, len(s.Meta.Integrations)) + copy(cp.Meta.Integrations, s.Meta.Integrations) + } + // Deep-copy Meta.HealthCheck pointer if s.Meta.HealthCheck != nil { hcCopy := *s.Meta.HealthCheck diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index a4e47cd..8c49de1 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -21,6 +21,7 @@ type Metadata struct { AppInfo AppInfo `yaml:"app_info" json:"app_info"` OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"` HealthCheck *HealthCheckConfig `yaml:"healthcheck,omitempty" json:"healthcheck,omitempty"` + Integrations []IntegrationDef `yaml:"integrations,omitempty" json:"integrations,omitempty"` } // AppInfo holds detailed app information for the info page. @@ -78,6 +79,13 @@ type SelectOption struct { Label string `yaml:"label" json:"label"` } +// IntegrationDef defines a single integration this app can provide to a target app. +type IntegrationDef struct { + Target string `yaml:"target" json:"target"` // target app slug: "filebrowser", "nextcloud" + Label string `yaml:"label" json:"label"` // UI label (Hungarian) + Description string `yaml:"description" json:"description"` // UI description +} + // HealthCheckConfig defines controller-side health probe configuration. // When configured, the controller periodically probes the app's container // and overrides the stack state to "unhealthy" if the service is not responding. @@ -210,3 +218,8 @@ func (m *Metadata) HasAppInfo() bool { func (m *Metadata) HasOptionalConfig() bool { return len(m.OptionalConfig) > 0 } + +// HasIntegrations returns true if the metadata defines any integrations. +func (m *Metadata) HasIntegrations() bool { + return len(m.Integrations) > 0 +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index 5c64008..b0274cb 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -478,6 +478,12 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s data["HasOptionalConfig"] = found.Meta.HasOptionalConfig() data["EffectiveSubdomain"] = effectiveSubdomain + // App-to-app integrations + if found.Meta.HasIntegrations() && s.integrationMgr != nil { + data["HasIntegrations"] = true + data["Integrations"] = s.integrationMgr.ListForProvider(found.Meta.Slug) + } + // Geo-restriction per-app data geo := s.settings.GetGeoRestriction() if geo != nil && geo.Enabled && s.cfg.Infrastructure.CFAPIToken != "" { @@ -1615,6 +1621,10 @@ func (s *Server) SyncFileBrowserMounts() { s.logger.Printf("[ERROR] Failed to recreate FileBrowser: %s — %v", string(out), err) } else { s.logger.Printf("[INFO] FileBrowser mounts synced — %d storage path(s), config updated", len(paths)) + // Re-apply active integrations (config regeneration overwrites config.yaml) + if s.integrationMgr != nil { + go s.integrationMgr.OnStackStart(context.Background(), "filebrowser") + } } } diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index a3363a6..6f271c3 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -15,6 +15,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/assets" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/integrations" "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" "gitea.dooplex.hu/admin/felhom-controller/internal/notify" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" @@ -74,6 +75,9 @@ type Server struct { // Asset syncer for Hub-managed assets (optional) assetsSyncer *assets.Syncer + // App-to-app integration manager (optional) + integrationMgr *integrations.Manager + // Debug mode support logBuffer *LogBuffer debugCallbacks *DebugCallbacks @@ -163,6 +167,11 @@ func (s *Server) SetAssetsSyncer(as *assets.Syncer) { s.assetsSyncer = as } +// SetIntegrationManager sets the app-to-app integration manager. +func (s *Server) SetIntegrationManager(mgr *integrations.Manager) { + s.integrationMgr = mgr +} + // SetLogBuffer sets the in-memory log ring buffer for the debug log viewer. func (s *Server) SetLogBuffer(lb *LogBuffer) { s.logBuffer = lb diff --git a/controller/internal/web/templates/app_info.html b/controller/internal/web/templates/app_info.html index d4e8a08..557f727 100644 --- a/controller/internal/web/templates/app_info.html +++ b/controller/internal/web/templates/app_info.html @@ -179,6 +179,67 @@ async function saveOptionalConfig(stackName) { {{end}} +{{if .HasIntegrations}} +
+

Integrációk

+

+ Más telepített alkalmazásokkal való összekapcsolás. Az integráció automatikusan felfüggesztődik, ha bármelyik alkalmazás leáll, és újraaktiválódik indításkor. +

+ + {{range .Integrations}} +
+
+ {{.Label}} +

{{.Description}}

+ {{if not .TargetDeployed}} + Nincs telepítve + {{else if not .TargetRunning}} + Célalkalmazás leállítva + {{else if eq .Status "error"}} + Hiba + {{else if .Enabled}} + Aktív + {{end}} +
+ +
+ {{end}} +
+ + +{{end}} + {{if .GeoGlobalEnabled}}

Földrajzi korlátozás