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:
@@ -1,5 +1,18 @@
|
|||||||
## Changelog
|
## 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)
|
### v0.30.7 — Monitoring: Fix Memory Legend Overflow (2026-02-25)
|
||||||
|
|
||||||
#### Fixed
|
#### Fixed
|
||||||
|
|||||||
+79
-1
@@ -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
|
## Repository Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
controller/
|
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/
|
├── internal/
|
||||||
│ ├── config/config.go # YAML loader, validation, env overrides
|
│ ├── config/config.go # YAML loader, validation, env overrides
|
||||||
│ ├── crypto/crypto.go # AES-256-GCM encryption for app.yaml secrets, key management
|
│ ├── 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
|
│ │ ├── waf.go # WAF rule CRUD + expression builders
|
||||||
│ │ ├── countries.go # ISO 3166-1 country codes + Hungarian names
|
│ │ ├── countries.go # ISO 3166-1 country codes + Hungarian names
|
||||||
│ │ └── geosync.go # Geo sync orchestrator (diff & apply rules)
|
│ │ └── 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)
|
│ ├── assets/syncer.go # Hub asset sync (download, SHA-256 compare, resolve)
|
||||||
│ ├── api/
|
│ ├── api/
|
||||||
│ │ ├── router.go # REST API endpoints (~36 routes)
|
│ │ ├── router.go # REST API endpoints (~36 routes)
|
||||||
@@ -1484,6 +1555,13 @@ Config endpoints accept session auth OR `Authorization: Bearer <hub_api_key>` (s
|
|||||||
| POST | `/api/assets/sync` | Trigger on-demand asset sync from Hub (async) |
|
| 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) |
|
| 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)
|
### Debug (debug mode only)
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
"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/metrics"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
"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)")
|
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 ---
|
// --- Initialize web server ---
|
||||||
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version)
|
webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, crossDriveRunner, sched, sett, alertMgr, notifier, updater, logger, Version)
|
||||||
webServer.SetEncryptionKey(encKey)
|
webServer.SetEncryptionKey(encKey)
|
||||||
|
webServer.SetIntegrationManager(integrationMgr)
|
||||||
webServer.SetStorageWatchdog(storageWatchdog)
|
webServer.SetStorageWatchdog(storageWatchdog)
|
||||||
if assetsSyncer != nil {
|
if assetsSyncer != nil {
|
||||||
webServer.SetAssetsSyncer(assetsSyncer)
|
webServer.SetAssetsSyncer(assetsSyncer)
|
||||||
@@ -917,6 +924,23 @@ func (a *stackAdapter) GetStackHDDPath(name string) string {
|
|||||||
return ""
|
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.
|
// geoStackAdapter implements cloudflare.StackLister for geo-restriction sync.
|
||||||
type geoStackAdapter struct {
|
type geoStackAdapter struct {
|
||||||
mgr *stacks.Manager
|
mgr *stacks.Manager
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
cf "gitea.dooplex.hu/admin/felhom-controller/internal/cloudflare"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||||
@@ -55,6 +56,9 @@ type Router struct {
|
|||||||
|
|
||||||
// Geo-restriction sync manager
|
// Geo-restriction sync manager
|
||||||
geoSync *cf.GeoSyncManager
|
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.
|
// 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
|
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 {
|
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}
|
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:
|
case path == "/config" && req.Method == http.MethodGet:
|
||||||
r.configContent(w, req)
|
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
|
// GET /api/stacks/{name}/deploy-fields
|
||||||
case hasSuffix(path, "/deploy-fields") && req.Method == http.MethodGet:
|
case hasSuffix(path, "/deploy-fields") && req.Method == http.MethodGet:
|
||||||
r.getDeployFields(w, req, extractName(path, "/deploy-fields"))
|
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 {
|
if r.OnGeoRelevantChange != nil {
|
||||||
go r.OnGeoRelevantChange()
|
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) {
|
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"})
|
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) {
|
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"})
|
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) {
|
func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name string) {
|
||||||
lines := 100
|
lines := 100
|
||||||
if v := req.URL.Query().Get("lines"); v != "" {
|
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)
|
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)
|
// Re-sync geo rules (hostname removed)
|
||||||
if r.OnGeoRelevantChange != nil {
|
if r.OnGeoRelevantChange != nil {
|
||||||
go r.OnGeoRelevantChange()
|
go r.OnGeoRelevantChange()
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -49,6 +49,17 @@ type Settings struct {
|
|||||||
|
|
||||||
// Geo-restriction settings (Cloudflare WAF rules)
|
// Geo-restriction settings (Cloudflare WAF rules)
|
||||||
GeoRestriction *GeoRestriction `json:"geo_restriction,omitempty"`
|
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.
|
// AppBackupPrefs holds per-app backup toggle state.
|
||||||
@@ -924,3 +935,65 @@ func (s *Settings) SetGeoSyncState(zoneID, rulesetID, syncError string) error {
|
|||||||
}
|
}
|
||||||
return s.save()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
// Deep-copy Meta.HealthCheck pointer
|
||||||
if s.Meta.HealthCheck != nil {
|
if s.Meta.HealthCheck != nil {
|
||||||
hcCopy := *s.Meta.HealthCheck
|
hcCopy := *s.Meta.HealthCheck
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type Metadata struct {
|
|||||||
AppInfo AppInfo `yaml:"app_info" json:"app_info"`
|
AppInfo AppInfo `yaml:"app_info" json:"app_info"`
|
||||||
OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"`
|
OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"`
|
||||||
HealthCheck *HealthCheckConfig `yaml:"healthcheck,omitempty" json:"healthcheck,omitempty"`
|
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.
|
// AppInfo holds detailed app information for the info page.
|
||||||
@@ -78,6 +79,13 @@ type SelectOption struct {
|
|||||||
Label string `yaml:"label" json:"label"`
|
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.
|
// HealthCheckConfig defines controller-side health probe configuration.
|
||||||
// When configured, the controller periodically probes the app's container
|
// When configured, the controller periodically probes the app's container
|
||||||
// and overrides the stack state to "unhealthy" if the service is not responding.
|
// 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 {
|
func (m *Metadata) HasOptionalConfig() bool {
|
||||||
return len(m.OptionalConfig) > 0
|
return len(m.OptionalConfig) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasIntegrations returns true if the metadata defines any integrations.
|
||||||
|
func (m *Metadata) HasIntegrations() bool {
|
||||||
|
return len(m.Integrations) > 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -478,6 +478,12 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
|||||||
data["HasOptionalConfig"] = found.Meta.HasOptionalConfig()
|
data["HasOptionalConfig"] = found.Meta.HasOptionalConfig()
|
||||||
data["EffectiveSubdomain"] = effectiveSubdomain
|
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-restriction per-app data
|
||||||
geo := s.settings.GetGeoRestriction()
|
geo := s.settings.GetGeoRestriction()
|
||||||
if geo != nil && geo.Enabled && s.cfg.Infrastructure.CFAPIToken != "" {
|
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)
|
s.logger.Printf("[ERROR] Failed to recreate FileBrowser: %s — %v", string(out), err)
|
||||||
} else {
|
} else {
|
||||||
s.logger.Printf("[INFO] FileBrowser mounts synced — %d storage path(s), config updated", len(paths))
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
"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/monitor"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
||||||
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
||||||
@@ -74,6 +75,9 @@ type Server struct {
|
|||||||
// Asset syncer for Hub-managed assets (optional)
|
// Asset syncer for Hub-managed assets (optional)
|
||||||
assetsSyncer *assets.Syncer
|
assetsSyncer *assets.Syncer
|
||||||
|
|
||||||
|
// App-to-app integration manager (optional)
|
||||||
|
integrationMgr *integrations.Manager
|
||||||
|
|
||||||
// Debug mode support
|
// Debug mode support
|
||||||
logBuffer *LogBuffer
|
logBuffer *LogBuffer
|
||||||
debugCallbacks *DebugCallbacks
|
debugCallbacks *DebugCallbacks
|
||||||
@@ -163,6 +167,11 @@ func (s *Server) SetAssetsSyncer(as *assets.Syncer) {
|
|||||||
s.assetsSyncer = as
|
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.
|
// SetLogBuffer sets the in-memory log ring buffer for the debug log viewer.
|
||||||
func (s *Server) SetLogBuffer(lb *LogBuffer) {
|
func (s *Server) SetLogBuffer(lb *LogBuffer) {
|
||||||
s.logBuffer = lb
|
s.logBuffer = lb
|
||||||
|
|||||||
@@ -179,6 +179,67 @@ async function saveOptionalConfig(stackName) {
|
|||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .HasIntegrations}}
|
||||||
|
<div class="app-optional-config">
|
||||||
|
<h3>Integrációk</h3>
|
||||||
|
<p class="config-group-desc">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{range .Integrations}}
|
||||||
|
<div class="config-field" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.75rem 0;border-bottom:1px solid var(--border)">
|
||||||
|
<div style="flex:1">
|
||||||
|
<strong>{{.Label}}</strong>
|
||||||
|
<p class="config-field-help" style="margin:0">{{.Description}}</p>
|
||||||
|
{{if not .TargetDeployed}}
|
||||||
|
<span class="badge badge-muted" style="margin-top:.25rem;display:inline-block">Nincs telepítve</span>
|
||||||
|
{{else if not .TargetRunning}}
|
||||||
|
<span class="badge badge-orphaned" style="margin-top:.25rem;display:inline-block">Célalkalmazás leállítva</span>
|
||||||
|
{{else if eq .Status "error"}}
|
||||||
|
<span class="badge badge-orphaned" style="margin-top:.25rem;display:inline-block">Hiba</span>
|
||||||
|
{{else if .Enabled}}
|
||||||
|
<span class="badge badge-ok" style="margin-top:.25rem;display:inline-block">Aktív</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox"
|
||||||
|
{{if .Enabled}}checked{{end}}
|
||||||
|
{{if not .TargetDeployed}}disabled title="A célalkalmazás nincs telepítve"{{end}}
|
||||||
|
{{if and .TargetDeployed (not .TargetRunning)}}disabled title="A célalkalmazás nem fut"{{end}}
|
||||||
|
onchange="toggleIntegration('{{$.Stack.Name}}', '{{.Target}}', this.checked, this)">
|
||||||
|
<span class="toggle-label"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function toggleIntegration(provider, target, enable, checkbox) {
|
||||||
|
checkbox.disabled = true;
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/api/integrations/' + provider + '/' + target, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
|
||||||
|
body: JSON.stringify({enable: enable})
|
||||||
|
});
|
||||||
|
var data = await resp.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
checkbox.checked = !enable;
|
||||||
|
alert(data.error || 'Hiba történt');
|
||||||
|
} else {
|
||||||
|
// Reload to update status badges
|
||||||
|
setTimeout(function(){ location.reload(); }, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
checkbox.checked = !enable;
|
||||||
|
alert('Hálózati hiba');
|
||||||
|
}
|
||||||
|
checkbox.disabled = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .GeoGlobalEnabled}}
|
{{if .GeoGlobalEnabled}}
|
||||||
<div class="app-optional-config">
|
<div class="app-optional-config">
|
||||||
<h3>Földrajzi korlátozás</h3>
|
<h3>Földrajzi korlátozás</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user