feat: app-to-app integration framework + OnlyOffice handlers

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 20:06:20 +01:00
parent d3b53d9877
commit 0a5840a255
15 changed files with 992 additions and 1 deletions
+95
View File
@@ -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()