From d121f74bd0e67c915b9a971711e5141ac6fdae4a Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Wed, 24 Jun 2026 02:24:39 +0200 Subject: [PATCH] Main update --- config/apps.php | 47 ++ config/widgets.php | 15 - docs/ANLEITUNG.md | 11 +- docs/README.md | 3 + .../03_MIGRATION_BESTEHENDE_MODULE.md | 30 + docs/WEITERENTWICKLUNG.md | 4 + modules/README.md | 35 ++ modules/boersenchecker/api/index.php | 78 +++ modules/boersenchecker/module.json | 46 ++ modules/fx-rates/module.json | 52 ++ modules/mining-checker/module.json | 7 + modules/pihole/api/index.php | 64 ++- modules/pihole/module.json | 74 +++ public/admin/cron/index.php | 338 ++++++++++++ public/admin/users/index.php | 36 +- public/api/boersenchecker/index.php | 30 + public/api/fx-rates/index.php | 22 +- public/assets/apps/cron-management/app.css | 186 +++++++ public/assets/apps/cron-management/app.js | 103 ++++ public/assets/apps/user-management/app.css | 4 + public/assets/apps/user-management/app.js | 39 ++ public/assets/desktop/desktop.css | 34 ++ public/assets/desktop/desktop.js | 170 +++++- src/App/App.php | 11 +- src/App/CronAuth.php | 52 ++ src/App/CronExpression.php | 217 ++++++++ src/App/CronManagementService.php | 515 ++++++++++++++++++ src/App/LdapProvisioner.php | 97 ++++ src/App/UserSelfManagementService.php | 39 +- src/Desktop/AppVisibility.php | 5 + src/Desktop/WidgetRegistry.php | 51 +- src/ModulesCore/LegacyCompat.php | 22 + src/ModulesCore/ModuleRegistry.php | 170 +++++- .../nexus-module-import/MODULE_DEVELOPMENT.md | 8 + 34 files changed, 2548 insertions(+), 67 deletions(-) create mode 100644 public/admin/cron/index.php create mode 100644 public/assets/apps/cron-management/app.css create mode 100644 public/assets/apps/cron-management/app.js create mode 100644 src/App/CronAuth.php create mode 100644 src/App/CronExpression.php create mode 100644 src/App/CronManagementService.php diff --git a/config/apps.php b/config/apps.php index a7415c1d..9d22c3d3 100644 --- a/config/apps.php +++ b/config/apps.php @@ -14,6 +14,8 @@ return [ 'supports_widget' => false, 'supports_tray' => false, 'module_name' => 'system', + 'app_scope' => 'core', + 'installable' => false, 'summary' => 'Startpunkt der Desktop-Shell mit Status zur Architektur, Skin-Umschaltung und naechsten Schritten.', ], [ @@ -27,6 +29,8 @@ return [ 'supports_widget' => true, 'supports_tray' => false, 'module_name' => 'desktop', + 'app_scope' => 'core', + 'installable' => false, 'summary' => 'Oeffentliche Startansicht fuer nicht eingeloggte Nutzer.', ], [ @@ -40,6 +44,8 @@ return [ 'supports_widget' => false, 'supports_tray' => false, 'module_name' => 'desktop', + 'app_scope' => 'core', + 'installable' => false, 'summary' => 'Vorbereitung fuer mehrere persoenliche Dashboards und Workspace-Verwaltung.', ], [ @@ -55,6 +61,8 @@ return [ 'supports_widget' => true, 'supports_tray' => false, 'module_name' => 'desktop', + 'app_scope' => 'system_tool', + 'installable' => false, 'summary' => 'Persoenliche Einstellungen fuer Desktop-Typ, Benutzerdaten, Apps und Widgets in einem gemeinsamen Setup-Bereich.', 'native_module' => [ 'initializer' => 'initUserSelfManagementApp', @@ -80,6 +88,8 @@ return [ 'supports_widget' => false, 'supports_tray' => true, 'module_name' => 'admin', + 'app_scope' => 'core', + 'installable' => false, 'summary' => 'Platzhalter fuer globale Verwaltung von Apps, Widgets, Integrationen und Suchmaschinen.', ], [ @@ -91,9 +101,12 @@ return [ 'content_mode' => 'native-module', 'default_width' => 1180, 'default_height' => 760, + 'show_on_desktop' => false, 'supports_widget' => false, 'supports_tray' => true, 'module_name' => 'admin', + 'app_scope' => 'system_tool', + 'installable' => false, 'required_groups' => ['administrators'], 'summary' => 'Benutzerverwaltung fuer Freischaltungen, Aktivierung, Deaktivierung und Passwort-Reset.', 'native_module' => [ @@ -111,6 +124,38 @@ return [ ], ], ], + [ + 'app_id' => 'cron-management', + 'title' => 'Cron Tool', + 'icon' => 'CR', + 'entry_route' => '/admin/cron/', + 'window_mode' => 'window', + 'content_mode' => 'native-module', + 'default_width' => 1220, + 'default_height' => 820, + 'show_on_desktop' => false, + 'supports_widget' => false, + 'supports_tray' => false, + 'module_name' => 'admin', + 'app_scope' => 'system_tool', + 'installable' => false, + 'required_groups' => ['administrators'], + 'summary' => 'Zentrale Verwaltung fuer moduluebergreifende Cron-Jobs, Zeitplaene und manuelle Testlaeufe.', + 'native_module' => [ + 'initializer' => 'initCronManagementApp', + 'options' => [ + 'entryRoute' => '/admin/cron/', + ], + 'assets' => [ + 'styles' => [ + ['href' => '/assets/apps/cron-management/app.css'], + ], + 'scripts' => [ + ['src' => '/assets/apps/cron-management/app.js'], + ], + ], + ], + ], [ 'app_id' => 'desktop-login', 'title' => 'Desktop Login', @@ -122,6 +167,8 @@ return [ 'supports_widget' => false, 'supports_tray' => false, 'module_name' => 'auth', + 'app_scope' => 'core', + 'installable' => false, 'summary' => 'Uebergabepunkt fuer das spaetere Keycloak-Theme im Desktop-Look.', ], ]; diff --git a/config/widgets.php b/config/widgets.php index df57e8f3..9df460e9 100644 --- a/config/widgets.php +++ b/config/widgets.php @@ -55,19 +55,4 @@ return [ 'summary' => 'Platz fuer persoenliche Hinweise und Notizen je Workspace.', 'content' => 'Dieser Bereich ist vorbereitet, aber standardmaessig noch nicht eingeblendet.', ], - [ - 'widget_id' => 'fx-rates-refresh', - 'title' => 'Waehrungskurse', - 'icon' => 'FX', - 'zone' => 'sidebar', - 'default_enabled' => true, - 'supports_public_home' => false, - 'summary' => 'Aktuelle FX-Kurse pruefen und bei Bedarf manuell aktualisieren.', - 'content' => 'Ein API-Abruf wird nur ausgefuehrt, wenn der letzte Snapshot alt genug ist, ausser bei explizitem Force.', - 'launch_app_id' => 'fx-rates', - 'action_label' => 'App oeffnen', - 'widget_type' => 'fx-rates-refresh', - 'status_api' => '/api/fx-rates/index.php?path=v1/status', - 'refresh_api' => '/api/fx-rates/index.php?path=v1/refresh', - ], ]; diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 36922e38..b08bec32 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -29,12 +29,13 @@ Das Startmenue ist in drei Bereiche gegliedert: 2. `Funktion-Bereich` Hier werden die Funktionsgruppen ausgewaehlt. Aktuell ist `Programme` die erste Gruppe. 3. `Auswahlbereich` - Hier erscheinen die Inhalte der aktuell gewaehlten Funktionsgruppe, zum Beispiel installierte Apps. + Hier erscheinen die Inhalte der aktuell gewaehlten Funktionsgruppe, gruppiert nach `Desktop`, `Systemtools` und `Modulen`. ## Programme oeffnen - Desktop-Icons koennen direkt geoeffnet werden. - Im Startmenue lassen sich Programme ueber den `Auswahlbereich` starten. +- `Systemtools` erscheinen dort getrennt von installierbaren `Modulen`. - Fenster koennen verschoben, minimiert, maximiert und geschlossen werden. - Programme koennen sowohl globale System-Apps als auch klassische Module sein. @@ -71,6 +72,14 @@ Aktuell gilt: - Modul-Businesslogik bleibt im Modul und wird nicht in den Desktop-Core verschoben - gemeinsame Desktop-Mechaniken wie Fenster, Asset-Einbindung und Zugriffsschutz werden global bereitgestellt +## Widgets und Cronjobs + +- Widgets koennen von Modulen bereitgestellt werden, wenn das Modul diese Funktion im Manifest hinterlegt. +- Widget-Funktionen stehen nur dann zur Auswahl, wenn die zugehoerige App fuer den Benutzer installiert oder aktiviert ist. +- Das `Cron Tool` ist ein globales `Systemtool` fuer Administratoren. +- Dort erscheinen Modul-Cronjobs automatisch, sobald ein Modul sie in `module.json` hinterlegt. +- Cron-Aufrufe laufen auf demselben Host und verwenden dieselbe Projektbasis wie der Desktop. + ## Waehrungs-Checker Der `Waehrungs-Checker` verwaltet gespeicherte Wechselkurse, Kurs-Historie, Umrechnungen und manuelle Aktualisierung. diff --git a/docs/README.md b/docs/README.md index f3599b2a..a3866974 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,3 +58,6 @@ Aktuell wichtig: - das gemeinsame Admin-Debug-Widget neben der Uhr ist der Standard fuer Live-Debugging in Desktop und Native-Apps - der Waehrungs-Checker stellt zusaetzlich ein Desktop-Widget fuer manuelle Kursaktualisierung bereit - das Oeffnen des Waehrungs-Checkers darf keinen externen Kursabruf ausloesen; Refreshes laufen nur nach Altersregel oder mit `force` +- `Systemtools` und installierbare `Module` werden getrennt behandelt; Systemtools sind nicht Teil der Benutzer-Installationsauswahl +- das `Cron Tool` ist als globales Systemtool vorhanden und sammelt Modul-Cronjobs automatisch ueber Manifest-Metadaten +- Widgets und Cron-Endpunkte sollen von Modulen bevorzugt direkt in `module.json` beschrieben werden diff --git a/docs/Umsetzungsanweisung/03_MIGRATION_BESTEHENDE_MODULE.md b/docs/Umsetzungsanweisung/03_MIGRATION_BESTEHENDE_MODULE.md index 033cc792..536d96f6 100644 --- a/docs/Umsetzungsanweisung/03_MIGRATION_BESTEHENDE_MODULE.md +++ b/docs/Umsetzungsanweisung/03_MIGRATION_BESTEHENDE_MODULE.md @@ -79,3 +79,33 @@ Wenn `Old-Nexus/` vorhanden ist: - `supports_widget` - `supports_tray` - `module_name` + +## Erweiterte Modul-Metadaten fuer die neue Desktop-Shell + +Importierte Module sollen bei der Migration nicht nur eine Fenster-App bekommen, sondern moeglichst direkt folgende Angaben im Manifest vorbereiten: + +- `app_scope: module` +- `installable: true` +- `desktop.available` +- `desktop.show_on_desktop` +- `desktop.show_in_start_menu` +- `widgets[]` +- `cron_jobs[]` + +Damit koennen folgende Dinge ohne weitere Sonderverdrahtung automatisch entstehen: + +- Desktop- und Startmenue-Sichtbarkeit +- Widget-Auswahl im User Self Management +- Aufnahme in die zentrale Cron-Verwaltung + +## Systemtools sind keine Module + +Globale Verwaltungsfunktionen werden in diesem Projekt nicht als installierbare Module behandelt. + +Beispiele: + +- `User Management` +- `User Self Management` +- `Cron Tool` + +Diese Apps laufen als `system_tool` und nicht als `module`. diff --git a/docs/WEITERENTWICKLUNG.md b/docs/WEITERENTWICKLUNG.md index e5c1b5d3..e95fe894 100644 --- a/docs/WEITERENTWICKLUNG.md +++ b/docs/WEITERENTWICKLUNG.md @@ -38,6 +38,8 @@ Verbindlich ist: - importierte Module mit eigener API ueber konsistente `public/api//index.php`-Shims an die gemeinsame Desktop-Auth anbinden - importierte Module mit eigener Fensterseite ueber konsistente `public/apps//index.php`-Shims anbinden - Widget-Logik einer App muss dieselben fachlichen Regeln wie die App-API verwenden, nicht eigene Sonderwege +- Systemtools duerfen nicht als installierbare Module modelliert werden +- neue Modul-Widgets und Modul-Cronjobs sollen ueber Manifest-Metadaten registriert werden statt ueber versteckte Sonderlisten ## NoGo's @@ -60,6 +62,8 @@ Verbindlich ist: - globale Debug-Steuerung fuer Admins liegt im Desktop-Core; Aktivierung erfolgt ueber das gemeinsame Debug-Widget neben der Uhr - API und Desktop teilen sich im aktuellen Zielbild denselben Host; offizielle Basis ist `/api/v1/...` - App, API und Widget einer Fachfunktion muessen dieselben Sperr- und Force-Regeln fuer externe Abrufe nutzen +- die Trennung `core` / `system_tool` / `module` ist in Metadaten, Startmenue und Benutzer-Setup konsistent zu halten +- Cron-Endpunkte muessen auf demselben Host laufen und duerfen keine separate Standard-API-Domain voraussetzen - Skins definieren Darstellung und Interaktionsdetails, nicht die Fachlogik - Hilfe- und Inhaltsdateien sollen spaeter maschinenlesbar oder zumindest klar strukturierbar in einen Hilfebereich ueberfuehrt werden koennen diff --git a/modules/README.md b/modules/README.md index ea89677b..f3796e36 100644 --- a/modules/README.md +++ b/modules/README.md @@ -13,9 +13,44 @@ Ein Desktop-Modul kann zusaetzlich diese Projektdateien besitzen: - `api/` fuer modulinterne HTTP-Endpunkte - `assets/` fuer modulnahe CSS- und JS-Dateien - `docs/README.md` fuer modulspezifische Hinweise, API und Sonderregeln +- `module.json` fuer Setup, Desktop-Freigabe, Widget-Funktionen und Cron-Endpunkte Module-Assets werden in diesem Projekt nicht direkt aus `public/assets/apps/...` dupliziert, sondern ueber den generischen Endpoint `/module-assets/index.php` aus dem jeweiligen Modul ausgeliefert. +## Trennung zwischen Systemtools und Modulen + +- `Systemtools` sind globale Verwaltungs- und Setup-Apps und nicht installierbar +- aktuelle Beispiele: `User Management`, `User Self Management`, `Cron Tool` +- `Module` sind installierbare Fachanwendungen +- aktuelle Beispiele: `Mining-Checker`, `Waehrungs-Checker`, `Boersenchecker`, `Pi-hole` + +Diese Trennung wird ueber App-Metadaten gesteuert: + +- `app_scope` + - `core` + - `system_tool` + - `module` +- `installable` + - `false` fuer Core- und Systemtools + - `true` fuer installierbare Module + +## Modul-Metadaten in `module.json` + +Module sollen ihre Desktop-Faehigkeiten zentral in `module.json` beschreiben. + +Wichtige Bausteine: + +- `desktop` + - `available` + - `show_on_desktop` + - `show_in_start_menu` +- `widgets` + - beschreibt Widget-Funktionen, die nur fuer installierte Module verfuegbar sind +- `cron_jobs` + - beschreibt Cron-Endpunkte fuer die zentrale Cron-Verwaltung + +Die Desktop-Shell liest diese Angaben automatisch ein. Neue Modul-Crons und Widgets muessen deshalb nicht zusaetzlich in einer separaten globalen Liste nachgetragen werden, solange sie sauber im Manifest beschrieben sind. + ## Globale Desktop-Standards fuer Module - gemeinsame Desktop-Mechaniken wie Fenster, Tray, globale Persistenz und Debug-Infrastruktur liegen im Desktop-Core diff --git a/modules/boersenchecker/api/index.php b/modules/boersenchecker/api/index.php index e60b1b8a..ae13e9c1 100644 --- a/modules/boersenchecker/api/index.php +++ b/modules/boersenchecker/api/index.php @@ -23,6 +23,84 @@ if ($normalized === 'v1/chart-data') { return; } +if ($normalized === 'v1/cron/refresh-quotes' && strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') { + header('Content-Type: application/json; charset=utf-8'); + try { + $result = module_fn('boersenchecker', 'scheduled_refresh_quotes', [ + 'trigger_source' => 'cron', + ]); + http_response_code(!empty($result['ok']) ? 200 : 500); + echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } catch (\Throwable $exception) { + http_response_code(500); + echo json_encode([ + 'ok' => false, + 'message' => $exception->getMessage(), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + return; +} + +if ($normalized === 'v1/widget/refresh-quotes' && strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') { + header('Content-Type: application/json; charset=utf-8'); + try { + $result = module_fn('boersenchecker', 'scheduled_refresh_quotes', [ + 'trigger_source' => 'widget', + ]); + http_response_code(!empty($result['ok']) ? 200 : 500); + echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } catch (\Throwable $exception) { + http_response_code(500); + echo json_encode([ + 'ok' => false, + 'message' => $exception->getMessage(), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + return; +} + +if ($normalized === 'v1/widget/status' && strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'GET') { + header('Content-Type: application/json; charset=utf-8'); + try { + $pdo = module_fn('boersenchecker', 'pdo'); + $quoteTable = module_fn('boersenchecker', 'table', 'quotes'); + $instrumentTable = module_fn('boersenchecker', 'table', 'instruments'); + $positionTable = module_fn('boersenchecker', 'table', 'positions'); + $settings = modules()->settings('boersenchecker'); + + $latestAt = $pdo->query('SELECT MAX(quoted_at) FROM ' . $quoteTable)->fetchColumn(); + $countStmt = $pdo->query( + 'SELECT COUNT(DISTINCT i.id) + FROM ' . $positionTable . ' p + INNER JOIN ' . $instrumentTable . ' i ON i.id = p.instrument_id + WHERE i.symbol IS NOT NULL + AND i.symbol <> \'\'' + ); + $trackedCount = (int) ($countStmt->fetchColumn() ?: 0); + + $statusText = $latestAt + ? 'Letzter Kursabruf: ' . date('d.m.Y H:i', strtotime((string) $latestAt)) . ' · ' . $trackedCount . ' Titel' + : 'Noch keine gespeicherten Kurse vorhanden.'; + + echo json_encode([ + 'ok' => true, + 'data' => [ + 'status_text' => $statusText, + 'latest_quoted_at' => $latestAt, + 'tracked_instruments' => $trackedCount, + 'report_currency' => (string) ($settings['report_currency'] ?? 'EUR'), + ], + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } catch (\Throwable $exception) { + http_response_code(500); + echo json_encode([ + 'ok' => false, + 'message' => $exception->getMessage(), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + return; +} + http_response_code(404); header('Content-Type: application/json; charset=utf-8'); echo json_encode([ diff --git a/modules/boersenchecker/module.json b/modules/boersenchecker/module.json index cf66a08e..14104fe0 100644 --- a/modules/boersenchecker/module.json +++ b/modules/boersenchecker/module.json @@ -3,6 +3,52 @@ "version": "0.2.0", "description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.", "enabled_by_default": true, + "app_scope": "module", + "installable": true, + "desktop": { + "available": true, + "show_on_desktop": true, + "show_in_start_menu": true + }, + "widgets": [ + { + "widget_id": "boersenchecker-refresh", + "title": "Boersenkurse", + "icon": "BC", + "zone": "sidebar", + "default_enabled": false, + "supports_public_home": false, + "summary": "Kurse fuer hinterlegte Depot-Positionen manuell nachziehen.", + "content": "Widget fuer Schnellabruf des letzten Kursstatus und manuellen Marktrefresh.", + "launch_app_id": "boersenchecker", + "action_label": "App oeffnen", + "widget_type": "action-panel", + "status_api": "/api/boersenchecker/index.php?path=v1/widget/status", + "actions": [ + { + "action_id": "refresh-quotes", + "label": "Kurse abrufen", + "endpoint": "/api/boersenchecker/index.php?path=v1/widget/refresh-quotes", + "method": "POST" + } + ] + } + ], + "cron_jobs": [ + { + "job_id": "refresh-quotes", + "label": "Boersenkurse aktualisieren", + "description": "Laedt fuer alle aktiven Depot-Positionen neue Alpha-Vantage-Kurse.", + "endpoint_path": "/api/boersenchecker/index.php?path=v1/cron/refresh-quotes", + "method": "POST", + "payload": { + "trigger_source": "cron" + }, + "default_enabled": false, + "default_cron": "15 */6 * * 1-5", + "lock_minutes": 20 + } + ], "setup": { "fields": [ { "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." }, diff --git a/modules/fx-rates/module.json b/modules/fx-rates/module.json index f8c674d9..a77094a0 100644 --- a/modules/fx-rates/module.json +++ b/modules/fx-rates/module.json @@ -4,6 +4,58 @@ "version": "0.1.0", "description": "Zentrales Modul fuer Waehrungskurse, Historie, API-Abrufe und Desktop-Widget.", "enabled_by_default": true, + "app_scope": "module", + "installable": true, + "desktop": { + "available": true, + "show_on_desktop": true, + "show_in_start_menu": true + }, + "widgets": [ + { + "widget_id": "fx-rates-refresh", + "title": "Waehrungskurse", + "icon": "FX", + "zone": "sidebar", + "default_enabled": true, + "supports_public_home": false, + "summary": "Aktuelle FX-Kurse pruefen und bei Bedarf manuell aktualisieren.", + "content": "Ein API-Abruf wird nur ausgefuehrt, wenn der letzte Snapshot alt genug ist, ausser bei explizitem Force.", + "launch_app_id": "fx-rates", + "action_label": "App oeffnen", + "widget_type": "fx-rates-refresh", + "status_api": "/api/fx-rates/index.php?path=v1/status", + "refresh_api": "/api/fx-rates/index.php?path=v1/refresh" + } + ], + "cron_jobs": [ + { + "job_id": "refresh-rates", + "label": "FX-Kurse aktualisieren", + "description": "Laedt neue Wechselkurse, sofern der letzte Snapshot nicht mehr frisch genug ist.", + "endpoint_path": "/api/fx-rates/index.php?path=v1/refresh", + "method": "POST", + "payload": { + "trigger_source": "cron" + }, + "default_enabled": false, + "default_cron": "0 */6 * * *", + "lock_minutes": 15 + }, + { + "job_id": "sync-currency-catalog", + "label": "FX-Waehrungskatalog synchronisieren", + "description": "Aktualisiert den internen Waehrungskatalog des Moduls.", + "endpoint_path": "/api/fx-rates/index.php?path=v1/currency-catalog/sync", + "method": "POST", + "payload": { + "trigger_source": "cron" + }, + "default_enabled": false, + "default_cron": "30 3 * * 1", + "lock_minutes": 30 + } + ], "setup": { "sections": { "database": true diff --git a/modules/mining-checker/module.json b/modules/mining-checker/module.json index 05fd56d5..9b7f57fb 100644 --- a/modules/mining-checker/module.json +++ b/modules/mining-checker/module.json @@ -4,6 +4,13 @@ "version": "0.3.0", "description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.", "enabled_by_default": true, + "app_scope": "module", + "installable": true, + "desktop": { + "available": true, + "show_on_desktop": true, + "show_in_start_menu": true + }, "setup": { "sections": { "database": true diff --git a/modules/pihole/api/index.php b/modules/pihole/api/index.php index c9617e19..75088a45 100644 --- a/modules/pihole/api/index.php +++ b/modules/pihole/api/index.php @@ -5,6 +5,7 @@ declare(strict_types=1); require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php'; require_once dirname(__DIR__) . '/bootstrap.php'; +use App\CronAuth; use ModulesCore\ModuleHttp; if (session_status() !== PHP_SESSION_ACTIVE) { @@ -12,11 +13,15 @@ if (session_status() !== PHP_SESSION_ACTIVE) { } $projectRoot = dirname(__DIR__, 3); -ModuleHttp::requireDesktopAccess($projectRoot, true); +$action = (string)($_GET['action'] ?? ''); +$cronAllowedActions = ['gravity', 'update', 'widget_status']; +$isCronRequest = in_array($action, $cronAllowedActions, true) && CronAuth::isAuthorizedRequest($projectRoot); + +if (!$isCronRequest) { + ModuleHttp::requireDesktopAccess($projectRoot, true); +} header('Content-Type: application/json; charset=utf-8'); - -$action = (string)($_GET['action'] ?? ''); $instances = module_fn('pihole', 'instances'); $listsPrimaryOnly = module_fn('pihole', 'lists_primary_only'); $moduleSettings = modules()->settings('pihole'); @@ -36,6 +41,22 @@ $respond = function (array $data, int $status = 200): void { exit; }; +$requireAdminAccess = static function (bool $allowCron = false) use ($isCronRequest, $respond): void { + if ($allowCron && $isCronRequest) { + return; + } + + $currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : []; + $groups = array_map( + static fn (string $group): string => strtolower(trim($group, '/')), + array_values(array_map('strval', (array) ($currentUser['groups'] ?? []))) + ); + + if (!in_array('administrators', $groups, true)) { + $respond(['ok' => false, 'error' => 'forbidden'], 403); + } +}; + $debugPush = static function (string $label, array $payload = []): void { module_debug_push('pihole', array_merge(['label' => $label], $payload)); }; @@ -886,8 +907,31 @@ if ($action === 'dashboard') { ]); } +if ($action === 'widget_status') { + $statusText = 'Keine Pi-hole-Instanzen konfiguriert.'; + if (!empty($instances)) { + $primaryId = $resolvePrimaryId(); + $primaryName = $primaryId !== null && isset($instances[$primaryId]) + ? (string) ($instances[$primaryId]['name'] ?? $primaryId) + : (string) array_key_first($instances); + $statusText = sprintf( + '%d Instanz(en) hinterlegt · Primaer: %s', + count($instances), + $primaryName !== '' ? $primaryName : 'n/a' + ); + } + + $respond([ + 'ok' => true, + 'data' => [ + 'status_text' => $statusText, + 'instance_count' => count($instances), + ], + ]); +} + if ($action === 'test') { - require_admin(); + $requireAdminAccess(); $target = (string)($payload['instance'] ?? ''); if ($target === '') { $respond(['ok' => false, 'error' => 'missing_instance'], 400); @@ -988,7 +1032,7 @@ if ($action === 'test') { } if ($action === 'disable') { - require_admin(); + $requireAdminAccess(); $debugPush('disable.request', ['payload' => $payload]); $minutes = (int)($payload['minutes'] ?? 0); $target = (string)($payload['instance'] ?? 'all'); @@ -1021,7 +1065,7 @@ if ($action === 'disable') { } if ($action === 'enable') { - require_admin(); + $requireAdminAccess(); $debugPush('enable.request', ['payload' => $payload]); $target = (string)($payload['instance'] ?? 'all'); $targets = $pickInstances($target); @@ -1049,7 +1093,7 @@ if ($action === 'enable') { } if ($action === 'gravity') { - require_admin(); + $requireAdminAccess(true); $debugPush('gravity.request', ['payload' => $payload]); $actionTimeout = $resolveActionTimeout(); $target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary'); @@ -1075,7 +1119,7 @@ if ($action === 'gravity') { } if ($action === 'domain_add') { - require_admin(); + $requireAdminAccess(); $debugPush('domain_add.request', ['payload' => $payload]); $domain = trim((string)($payload['domain'] ?? '')); $type = (string)($payload['type'] ?? 'block'); @@ -1109,7 +1153,7 @@ if ($action === 'domain_add') { } if ($action === 'adlist_add') { - require_admin(); + $requireAdminAccess(); $debugPush('adlist_add.request', ['payload' => $payload]); $url = trim((string)($payload['url'] ?? '')); if ($url === '') { @@ -1138,7 +1182,7 @@ if ($action === 'adlist_add') { } if ($action === 'update') { - require_admin(); + $requireAdminAccess(true); $debugPush('update.request', ['payload' => $payload]); $actionTimeout = $resolveActionTimeout(); $target = (string)($payload['instance'] ?? 'primary'); diff --git a/modules/pihole/module.json b/modules/pihole/module.json index db203175..0594a317 100644 --- a/modules/pihole/module.json +++ b/modules/pihole/module.json @@ -3,6 +3,80 @@ "version": "0.1.0", "description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.", "enabled_by_default": true, + "app_scope": "module", + "installable": true, + "desktop": { + "available": true, + "show_on_desktop": true, + "show_in_start_menu": true + }, + "widgets": [ + { + "widget_id": "pihole-maintenance", + "title": "Pi-hole Wartung", + "icon": "PH", + "zone": "sidebar", + "default_enabled": false, + "supports_public_home": false, + "summary": "Gravity-Update und Listen-Refresh direkt aus dem Desktop.", + "content": "Schnellaktionen fuer die Pflege der Pi-hole-Instanzen.", + "launch_app_id": "pihole", + "action_label": "App oeffnen", + "widget_type": "action-panel", + "status_api": "/api/pihole/index.php?action=widget_status", + "actions": [ + { + "action_id": "gravity", + "label": "Gravity aktualisieren", + "endpoint": "/api/pihole/index.php?action=gravity", + "method": "POST", + "payload": { + "instance": "primary" + } + }, + { + "action_id": "update", + "label": "Pi-hole updaten", + "endpoint": "/api/pihole/index.php?action=update", + "method": "POST", + "payload": { + "instance": "primary" + }, + "variant": "secondary" + } + ] + } + ], + "cron_jobs": [ + { + "job_id": "gravity-primary", + "label": "Pi-hole Gravity Update", + "description": "Aktualisiert die Blocklisten auf der Primaer-Instanz.", + "endpoint_path": "/api/pihole/index.php?action=gravity", + "method": "POST", + "payload": { + "instance": "primary", + "trigger_source": "cron" + }, + "default_enabled": false, + "default_cron": "20 4 * * *", + "lock_minutes": 30 + }, + { + "job_id": "update-primary", + "label": "Pi-hole Systemupdate", + "description": "Startet ein Update der Primaer-Instanz.", + "endpoint_path": "/api/pihole/index.php?action=update", + "method": "POST", + "payload": { + "instance": "primary", + "trigger_source": "cron" + }, + "default_enabled": false, + "default_cron": "50 4 * * 0", + "lock_minutes": 45 + } + ], "setup": { "fields": [ { diff --git a/public/admin/cron/index.php b/public/admin/cron/index.php new file mode 100644 index 00000000..189c371e --- /dev/null +++ b/public/admin/cron/index.php @@ -0,0 +1,338 @@ +isAuthenticated()) { + header('Location: /auth/keycloak', true, 302); + exit; +} + +$currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : []; +$currentGroups = array_map( + static fn (string $group): string => strtolower(trim($group, '/')), + array_values(array_map('strval', (array) ($currentUser['groups'] ?? []))) +); +$isPartial = !empty($_GET['partial']); + +if (!in_array('administrators', $currentGroups, true)) { + http_response_code(403); + $forbidden = '

Kein Zugriff auf Cron Tool.

'; + if ($isPartial) { + echo $forbidden; + return; + } + + ?> + + + + + Cron Tool + + + + + saveGlobal([ + 'base_url' => (string) ($_POST['base_url'] ?? ''), + 'default_timezone' => (string) ($_POST['default_timezone'] ?? ''), + ], $baseUrlFallback); + $messages[] = 'Globale Cron-Einstellungen gespeichert.'; + } elseif ($action === 'regenerate-token') { + $service->regenerateToken($baseUrlFallback); + $messages[] = 'Cron-Token wurde neu erzeugt.'; + } elseif ($action === 'run-due') { + $results = $service->runDue($baseUrlFallback); + if ($results === []) { + $messages[] = 'Aktuell war kein Cron-Job faellig.'; + } else { + foreach ($results as $result) { + $messages[] = sprintf( + '%s: %s', + (string) ($result['job_key'] ?? 'Cron-Job'), + (string) ($result['message'] ?? '') + ); + } + } + } elseif ($action === 'save-job') { + $jobKey = (string) ($_POST['job_key'] ?? ''); + $service->saveJob($jobKey, [ + 'enabled' => !empty($_POST['enabled']), + 'cron_expression' => (string) ($_POST['cron_expression'] ?? ''), + 'timezone' => (string) ($_POST['timezone'] ?? ''), + ], $baseUrlFallback); + $messages[] = 'Cron-Job gespeichert: ' . $jobKey; + } elseif ($action === 'run-now') { + $jobKey = (string) ($_POST['job_key'] ?? ''); + $result = $service->runNow($jobKey, $baseUrlFallback); + if (!empty($result['ok'])) { + $messages[] = (string) ($result['message'] ?? 'Cron-Job ausgefuehrt.'); + } else { + $errors[] = (string) ($result['message'] ?? 'Cron-Job fehlgeschlagen.'); + } + } + } catch (\Throwable $exception) { + $errors[] = $exception->getMessage(); + } + } +} + +$bootstrap = $service->bootstrap($baseUrlFallback); +$settings = is_array($bootstrap['settings'] ?? null) ? $bootstrap['settings'] : []; +$jobs = is_array($bootstrap['jobs'] ?? null) ? $bootstrap['jobs'] : []; +$h = static fn (?string $value): string => htmlspecialchars($value ?? '', ENT_QUOTES); + +ob_start(); +?> +
+
+ + +
+
+
+
+

Cron Registry

+

Globale Scheduler-Verwaltung

+

Module hinterlegen ihre Cron-Endpunkte in der Manifest-Datei. Dieses Systemtool sammelt sie automatisch und verwaltet Zeitplaene zentral.

+
+
+ Base URL: + Token aktiv + Admin only +
+
+ + +
+
+ Ergebnis +
    + +
  • + +
+
+
+ + + +
+
+ Bitte pruefen +
    + +
  • + +
+
+
+ + +
+
+
+

Global

+

Runner-Einstellungen

+
+
+ +
+ + + + +
+ Cron-Token + +

Der Token wird fuer interne Cron-Aufrufe per `X-Desktop-Cron-Token` genutzt.

+
+
+ +
+
+ +
+ + + +
+ +
+ + + +
+
+ +
+
+
+

Jobs

+

Erkannte Modul-Crons

+
+
+ +
+ +
+
+
+ +

+
+
+ + +
+
+ +
+
Status
+
Naechster Lauf
+
Letzter Erfolg
+
Endpoint
+
+ + +

Cron-Fehler:

+ + +

+ + +
+ + + + + + +
+ +
+
+ +
+ + + + +
+
+ + +
Aktuell wurden noch keine Cron-Jobs aus Modulen registriert.
+ +
+
+
+
+
+
+ + + + + + + + + + + Cron Tool + + + + + + + + + diff --git a/public/admin/users/index.php b/public/admin/users/index.php index 9d856f9f..35dca2e2 100644 --- a/public/admin/users/index.php +++ b/public/admin/users/index.php @@ -134,6 +134,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $pendingRequests = $registration->pendingApprovals(); $availableGroups = $registration->availableGroups(); $existingUsers = $registration->searchExistingUsers('', 250); +$selectedExistingUser = null; +if ($manualUsername !== '') { + foreach ($existingUsers as $existingUser) { + if (strtolower((string) ($existingUser['username'] ?? '')) === strtolower($manualUsername)) { + $selectedExistingUser = $existingUser; + break; + } + } +} $selected = $selectedId !== '' ? $registration->find($selectedId) : ($pendingRequests[0] ?? null); @@ -348,17 +357,22 @@ ob_start();
Gruppen fuer Aktivierung -
- - - -
+
+ + + + +
diff --git a/public/api/boersenchecker/index.php b/public/api/boersenchecker/index.php index 3783b906..1db92517 100644 --- a/public/api/boersenchecker/index.php +++ b/public/api/boersenchecker/index.php @@ -2,4 +2,34 @@ declare(strict_types=1); +require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php'; + +use App\CronAuth; +use ModulesCore\ModuleHttp; + +if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); +} + +$projectRoot = dirname(__DIR__, 3); +$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? ''); +$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: ''); +$prefix = '/api/boersenchecker/index.php/'; +$relativePath = trim((string) ($_GET['path'] ?? ''), '/'); + +if ($relativePath === '') { + $relativePath = str_starts_with($requestPath, $prefix) + ? substr($requestPath, strlen($prefix)) + : ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/'); +} + +$normalizedPath = strtolower(trim($relativePath, '/')); +$cronPaths = [ + 'v1/cron/refresh-quotes', +]; + +if (!(in_array($normalizedPath, $cronPaths, true) && CronAuth::isAuthorizedRequest($projectRoot))) { + ModuleHttp::requireDesktopAccess($projectRoot, true); +} + require dirname(__DIR__, 3) . '/modules/boersenchecker/api/index.php'; diff --git a/public/api/fx-rates/index.php b/public/api/fx-rates/index.php index 59fc514b..a97c334a 100644 --- a/public/api/fx-rates/index.php +++ b/public/api/fx-rates/index.php @@ -4,11 +4,31 @@ declare(strict_types=1); require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php'; +use App\CronAuth; use ModulesCore\ModuleHttp; session_start(); $projectRoot = dirname(__DIR__, 3); -ModuleHttp::requireDesktopAccess($projectRoot, true); +$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? ''); +$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: ''); +$prefix = '/api/fx-rates/index.php/'; +$relativePath = trim((string) ($_GET['path'] ?? ''), '/'); + +if ($relativePath === '') { + $relativePath = str_starts_with($requestPath, $prefix) + ? substr($requestPath, strlen($prefix)) + : ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/'); +} + +$normalizedPath = strtolower(trim($relativePath, '/')); +$cronPaths = [ + 'v1/refresh', + 'v1/currency-catalog/sync', +]; + +if (!(in_array($normalizedPath, $cronPaths, true) && CronAuth::isAuthorizedRequest($projectRoot))) { + ModuleHttp::requireDesktopAccess($projectRoot, true); +} require $projectRoot . '/modules/fx-rates/api/index.php'; diff --git a/public/assets/apps/cron-management/app.css b/public/assets/apps/cron-management/app.css new file mode 100644 index 00000000..c2fda945 --- /dev/null +++ b/public/assets/apps/cron-management/app.css @@ -0,0 +1,186 @@ +#cron-management-app { + min-height: 100%; + color: #0f172a; + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; +} + +#cron-management-app, +#cron-management-app * { + box-sizing: border-box; +} + +#cron-management-app.is-loading { + opacity: 0.72; + pointer-events: none; +} + +#cron-management-app .cm-shell, +#cron-management-app .cm-frame, +#cron-management-app .cm-main, +#cron-management-app .cm-panel { + min-height: 100%; +} + +#cron-management-app .cm-brand, +#cron-management-app .cm-copy { + margin: 0; +} + +#cron-management-app .cm-kicker { + margin: 0 0 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #2563eb; +} + +#cron-management-app .cm-card, +#cron-management-app .cm-job-card { + display: grid; + gap: 14px; +} + +#cron-management-app .cm-side-title, +#cron-management-app .cm-section-title { + margin: 0; + font-size: 18px; + line-height: 1.2; +} + +#cron-management-app .cm-side-metric, +#cron-management-app .cm-field, +#cron-management-app .cm-token-box { + display: grid; + gap: 6px; +} + +#cron-management-app .cm-side-metric span, +#cron-management-app .cm-field span, +#cron-management-app .cm-token-box p, +#cron-management-app .cm-job-card p, +#cron-management-app .cm-message, +#cron-management-app .cm-empty { + font-size: 14px; + line-height: 1.5; + color: #475569; +} + +#cron-management-app .cm-form { + display: grid; + gap: 14px; +} + +#cron-management-app .cm-form--global { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + align-items: end; +} + +#cron-management-app .cm-field input, +#cron-management-app .cm-token-box, +#cron-management-app .cm-status-grid > div { + width: 100%; + min-height: 46px; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.24); + background: rgba(255, 255, 255, 0.95); + color: #0f172a; + font: inherit; +} + +#cron-management-app .cm-token-box code { + display: block; + overflow-wrap: anywhere; + font-size: 13px; +} + +#cron-management-app .cm-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +#cron-management-app .cm-inline-form { + display: inline-flex; +} + +#cron-management-app .cm-primary { + background: linear-gradient(135deg, #312e81, #4338ca); + color: #fff; +} + +#cron-management-app .cm-secondary { + background: #eff6ff; + color: #1d4ed8; +} + +#cron-management-app .cm-job-list { + display: grid; + gap: 16px; +} + +#cron-management-app .cm-job-head, +#cron-management-app .cm-section-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} + +#cron-management-app .cm-job-meta { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +#cron-management-app .cm-status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +#cron-management-app .cm-status-grid strong { + display: block; + margin-top: 4px; + color: #0f172a; +} + +#cron-management-app .cm-check { + display: inline-flex; + gap: 10px; + align-items: center; + font-size: 14px; + color: #0f172a; +} + +#cron-management-app .cm-message { + padding: 14px 16px; + border-radius: 14px; +} + +#cron-management-app .cm-message.is-success { + background: rgba(34, 197, 94, 0.12); + color: #166534; +} + +#cron-management-app .cm-message.is-error, +#cron-management-app .cm-parse-error { + background: rgba(239, 68, 68, 0.1); + color: #991b1b; + padding: 12px 14px; + border-radius: 14px; +} + +#cron-management-app .cm-list { + margin: 8px 0 0; + padding-left: 18px; +} + +@media (max-width: 900px) { + #cron-management-app .cm-form--global, + #cron-management-app .cm-status-grid { + grid-template-columns: 1fr; + } +} diff --git a/public/assets/apps/cron-management/app.js b/public/assets/apps/cron-management/app.js new file mode 100644 index 00000000..eb24f43e --- /dev/null +++ b/public/assets/apps/cron-management/app.js @@ -0,0 +1,103 @@ +(() => { + const normalizeUrl = (url) => { + const next = new URL(url, window.location.origin); + next.searchParams.set('partial', '1'); + return next.toString(); + }; + + const renderError = (host, message) => { + host.innerHTML = ` +
+
+
+
+
+

${String(message || 'Cron Tool konnte nicht geladen werden.')}

+
+
+
+
+
+ `; + }; + + const bindInteractions = (host, loadRoute) => { + host.querySelectorAll('form[action]').forEach((form) => { + if (form.dataset.cmBound === '1') { + return; + } + + const action = String(form.getAttribute('action') || ''); + if (!action.startsWith('/admin/cron/')) { + return; + } + + form.dataset.cmBound = '1'; + form.addEventListener('submit', async (event) => { + event.preventDefault(); + + try { + host.classList.add('is-loading'); + const response = await fetch(normalizeUrl(action), { + method: 'POST', + credentials: 'same-origin', + body: new FormData(form), + }); + + const html = await response.text(); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + host.innerHTML = html; + bindInteractions(host, loadRoute); + } catch (error) { + renderError(host, error?.message || 'Cron Tool konnte nicht geladen werden.'); + } finally { + host.classList.remove('is-loading'); + } + }); + }); + }; + + window.initCronManagementApp = function initCronManagementApp(host, options = {}) { + if (!(host instanceof Element)) { + return; + } + + if (host.dataset.cmAppBound === '1') { + return; + } + + host.dataset.cmAppBound = '1'; + + const entryRoute = String(options.entryRoute || '/admin/cron/'); + const loadRoute = async (route) => { + try { + host.classList.add('is-loading'); + const response = await fetch(normalizeUrl(route), { + credentials: 'same-origin', + headers: { Accept: 'text/html' }, + }); + const html = await response.text(); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + host.innerHTML = html; + bindInteractions(host, loadRoute); + } catch (error) { + renderError(host, error?.message || 'Cron Tool konnte nicht geladen werden.'); + } finally { + host.classList.remove('is-loading'); + } + }; + + if (options.standalone) { + bindInteractions(host, loadRoute); + return; + } + + loadRoute(entryRoute); + }; +})(); diff --git a/public/assets/apps/user-management/app.css b/public/assets/apps/user-management/app.css index d4f75309..5d81ceaa 100644 --- a/public/assets/apps/user-management/app.css +++ b/public/assets/apps/user-management/app.css @@ -192,6 +192,10 @@ box-shadow: 0 20px 44px rgba(15, 23, 42, 0.12); } +#user-management-app .um-user-picker-list[hidden] { + display: none !important; +} + #user-management-app .um-user-picker-option { display: grid; gap: 2px; diff --git a/public/assets/apps/user-management/app.js b/public/assets/apps/user-management/app.js index f3899469..57541117 100644 --- a/public/assets/apps/user-management/app.js +++ b/public/assets/apps/user-management/app.js @@ -48,6 +48,7 @@ username: String(user?.username || ''), display_name: String(user?.display_name || ''), email: String(user?.email || ''), + group_dns: Array.isArray(user?.group_dns) ? user.group_dns.map((group) => String(group || '')) : [], search: [ String(user?.username || ''), String(user?.display_name || ''), @@ -74,6 +75,35 @@ } input.dataset.umPickerBound = '1'; + const syncGroupSelection = (username) => { + const normalizedUsername = String(username || '').trim().toLowerCase(); + host.querySelectorAll('input[name="manual_group_dns[]"]').forEach((checkbox) => { + if (!(checkbox instanceof HTMLInputElement)) { + return; + } + + checkbox.checked = false; + }); + + if (normalizedUsername === '') { + return; + } + + const user = normalizedUsers.find((entry) => entry.username.toLowerCase() === normalizedUsername); + if (!user) { + return; + } + + const selectedGroups = new Set(user.group_dns); + host.querySelectorAll('input[name="manual_group_dns[]"]').forEach((checkbox) => { + if (!(checkbox instanceof HTMLInputElement)) { + return; + } + + checkbox.checked = selectedGroups.has(checkbox.value); + }); + }; + const renderMatches = () => { const needle = input.value.trim().toLowerCase(); const matches = normalizedUsers @@ -109,8 +139,17 @@ input.value = String(option.dataset.userPickerValue || ''); list.hidden = true; list.innerHTML = ''; + syncGroupSelection(input.value); input.dispatchEvent(new Event('change', { bubbles: true })); }); + + input.addEventListener('change', () => { + syncGroupSelection(input.value); + }); + + if (input.value.trim() !== '') { + syncGroupSelection(input.value); + } }); if (!host.dataset.umPickerOutsideBound) { diff --git a/public/assets/desktop/desktop.css b/public/assets/desktop/desktop.css index 1e20a94a..2bbaf5ae 100644 --- a/public/assets/desktop/desktop.css +++ b/public/assets/desktop/desktop.css @@ -1228,6 +1228,40 @@ h1 { align-content: start; } +.start-menu-group, +.start-menu-group-list { + display: grid; + gap: 10px; +} + +.start-menu-group { + gap: 12px; + padding-top: 6px; +} + +.start-menu-group + .start-menu-group { + margin-top: 6px; + padding-top: 16px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.start-menu-group-header { + display: grid; + gap: 4px; + padding: 0 4px; +} + +.start-menu-group-header strong { + font-size: 12px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.88); +} + +.start-menu-group-header small { + color: rgba(248, 250, 252, 0.58); +} + .start-menu-selection-header { display: grid; gap: 10px; diff --git a/public/assets/desktop/desktop.js b/public/assets/desktop/desktop.js index 7a79d1ab..0a105762 100644 --- a/public/assets/desktop/desktop.js +++ b/public/assets/desktop/desktop.js @@ -1612,6 +1612,92 @@ if (payloadNode) { await loadStatus(); }; + const renderActionPanelWidget = async (card, widget) => { + const statusNode = card.querySelector('[data-widget-status]'); + const messageNode = card.querySelector('[data-widget-message]'); + const actionButtons = Array.from(card.querySelectorAll('[data-widget-action]')); + + const loadStatus = async () => { + if (!(statusNode instanceof HTMLElement) || !widget.status_api) { + return; + } + + statusNode.textContent = 'Status wird geladen ...'; + + try { + const response = await fetch(widget.status_api, { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.message || payload?.error || `HTTP ${response.status}`); + } + + const data = payload?.data || {}; + statusNode.textContent = String(data.status_text || data.message || 'Status geladen.'); + } catch (error) { + statusNode.textContent = error instanceof Error ? error.message : 'Status konnte nicht geladen werden.'; + } + }; + + const runAction = async (actionConfig) => { + if (!(messageNode instanceof HTMLElement) || !actionConfig || typeof actionConfig !== 'object') { + return; + } + + const method = String(actionConfig.method || 'POST').toUpperCase(); + const endpoint = String(actionConfig.endpoint || ''); + if (endpoint === '') { + return; + } + + messageNode.textContent = 'Aktion wird ausgefuehrt ...'; + + try { + const response = await fetch(endpoint, { + method, + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: method === 'GET' + ? null + : JSON.stringify(actionConfig.payload && typeof actionConfig.payload === 'object' ? actionConfig.payload : {}), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.message || payload?.error || `HTTP ${response.status}`); + } + + messageNode.textContent = String( + payload?.message + || payload?.data?.message + || payload?.data?.status_text + || 'Aktion erfolgreich ausgefuehrt.' + ); + await loadStatus(); + } catch (error) { + messageNode.textContent = error instanceof Error ? error.message : 'Aktion fehlgeschlagen.'; + } + }; + + actionButtons.forEach((button) => { + button.addEventListener('click', () => { + const actionId = button.getAttribute('data-widget-action') || ''; + const actionConfig = Array.isArray(widget.actions) + ? widget.actions.find((entry) => String(entry?.action_id || '') === actionId) + : null; + if (actionConfig) { + runAction(actionConfig); + } + }); + }); + + await loadStatus(); + }; + const renderWidgets = () => { if (!widgetZoneRoot) { return; @@ -1643,6 +1729,21 @@ if (payloadNode) { ${widget.launch_app_id ? `` : ''}
` + : widget.widget_type === 'action-panel' + ? ` +
${widget.content || 'Status wird geladen ...'}
+

${widget.summary || ''}

+
+ ${(Array.isArray(widget.actions) ? widget.actions : []).map((action) => ` + + `).join('')} + ${widget.launch_app_id ? `` : ''} +
+ ` : `

${widget.content || ''}

${actionMarkup}`; card.innerHTML = `
@@ -1668,6 +1769,8 @@ if (payloadNode) { widgetZoneRoot.appendChild(card); if (widget.widget_type === 'fx-rates-refresh') { renderFxRatesWidget(card, widget); + } else if (widget.widget_type === 'action-panel') { + renderActionPanelWidget(card, widget); } }); }; @@ -1706,23 +1809,58 @@ if (payloadNode) { return; } - payload.apps.forEach((app) => { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'start-menu-entry start-menu-entry--app'; - button.innerHTML = ` - ${buildAppIconMarkup(app, 'start-menu-app-icon')} - - ${app.title} - ${app.summary || ''} - - Open - `; - button.addEventListener('click', () => { - openApp(app); - closeStartMenu(); + const groups = [ + { id: 'core', label: 'Desktop', description: 'Globale Bereiche und Shell-Funktionen' }, + { id: 'system_tool', label: 'Systemtools', description: 'Verwaltung und Setup' }, + { id: 'module', label: 'Module', description: 'Installierte Fachanwendungen' }, + ]; + + groups.forEach((group) => { + const apps = payload.apps.filter((app) => { + if (app.show_in_start_menu === false) { + return false; + } + + return String(app.app_scope || 'module') === group.id; }); - startMenuApps.appendChild(button); + + if (apps.length === 0) { + return; + } + + const section = document.createElement('section'); + section.className = 'start-menu-group'; + section.innerHTML = ` +
+ ${group.label} + ${group.description} +
+ `; + + const list = document.createElement('div'); + list.className = 'start-menu-group-list'; + + apps.forEach((app) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'start-menu-entry start-menu-entry--app'; + button.innerHTML = ` + ${buildAppIconMarkup(app, 'start-menu-app-icon')} + + ${app.title} + ${app.summary || ''} + + Open + `; + button.addEventListener('click', () => { + openApp(app); + closeStartMenu(); + }); + list.appendChild(button); + }); + + section.appendChild(list); + startMenuApps.appendChild(section); }); }; diff --git a/src/App/App.php b/src/App/App.php index 6c2ceebb..3ae17882 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -29,7 +29,7 @@ final class App $auth = new KeycloakAuth($keycloakConfig); $moduleRegistry = new ModuleRegistry($this->projectRoot . '/modules'); $registry = new AppRegistry($this->projectRoot . '/config/apps.php', $moduleRegistry->desktopApps()); - $widgetRegistry = new WidgetRegistry($this->projectRoot . '/config/widgets.php'); + $widgetRegistry = new WidgetRegistry($this->projectRoot . '/config/widgets.php', $moduleRegistry->widgets()); $skins = SkinResolver::all(); $isAuthenticated = isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']); $authUser = $isAuthenticated && is_array($_SESSION['desktop_auth']['user'] ?? null) @@ -55,6 +55,11 @@ final class App ); $apps = AppIconResolver::decorate($enabledApps, $activeSkin); $windows = (new WindowManager($registry))->defaultWindows(); + $enabledAppIds = array_values(array_filter(array_map( + static fn (array $app): string => (string) ($app['app_id'] ?? ''), + $enabledApps + ))); + $availableWidgets = $widgetRegistry->availableForApps($enabledAppIds); $activeWidgetIds = array_values(array_map('strval', (array) ($userSettings['widgets']['active_ids'] ?? DesktopState::defaultWidgetIds()))); $trayItems = DesktopState::trayItems(); foreach ($enabledApps as $app) { @@ -103,8 +108,8 @@ final class App 'apps' => $apps, 'windows' => $windows, 'widgets' => [ - 'active' => $widgetRegistry->active($activeWidgetIds), - 'registry' => $widgetRegistry->all(), + 'active' => $widgetRegistry->active($activeWidgetIds, $enabledAppIds), + 'registry' => $availableWidgets, 'active_ids' => $activeWidgetIds, ], 'tray' => $trayItems, diff --git a/src/App/CronAuth.php b/src/App/CronAuth.php new file mode 100644 index 00000000..828f8bc6 --- /dev/null +++ b/src/App/CronAuth.php @@ -0,0 +1,52 @@ +read(); + $token = trim((string) ($settings['cron_token'] ?? '')); + + return $token !== '' ? $token : null; + } + + public static function providedToken(): ?string + { + $headers = function_exists('getallheaders') ? (array) getallheaders() : []; + $candidates = [ + $headers['X-Desktop-Cron-Token'] ?? null, + $headers['x-desktop-cron-token'] ?? null, + $_SERVER['HTTP_X_DESKTOP_CRON_TOKEN'] ?? null, + $_GET['cron_token'] ?? null, + $_POST['cron_token'] ?? null, + ]; + + foreach ($candidates as $candidate) { + $token = trim((string) $candidate); + if ($token !== '') { + return $token; + } + } + + return null; + } + + private static function settingsStore(string $projectRoot): JsonStore + { + return new JsonStore(rtrim($projectRoot, '/') . '/data/system/cron/settings.json'); + } +} diff --git a/src/App/CronExpression.php b/src/App/CronExpression.php new file mode 100644 index 00000000..6a902f3c --- /dev/null +++ b/src/App/CronExpression.php @@ -0,0 +1,217 @@ + */ + private array $minutes; + /** @var array */ + private array $hours; + /** @var array */ + private array $daysOfMonth; + /** @var array */ + private array $months; + /** @var array */ + private array $daysOfWeek; + + private function __construct( + private string $expression, + array $minutes, + array $hours, + array $daysOfMonth, + array $months, + array $daysOfWeek, + private bool $daysOfMonthWildcard, + private bool $daysOfWeekWildcard + ) { + $this->minutes = $minutes; + $this->hours = $hours; + $this->daysOfMonth = $daysOfMonth; + $this->months = $months; + $this->daysOfWeek = $daysOfWeek; + } + + public static function parse(string $expression): self + { + $normalized = preg_replace('/\s+/', ' ', trim($expression)) ?? ''; + if ($normalized === '') { + throw new \InvalidArgumentException('Cron-Ausdruck fehlt.'); + } + + $parts = explode(' ', $normalized); + if (count($parts) !== 5) { + throw new \InvalidArgumentException('Cron-Ausdruck muss aus 5 Feldern bestehen.'); + } + + return new self( + $normalized, + self::parseField($parts[0], 0, 59), + self::parseField($parts[1], 0, 23), + self::parseField($parts[2], 1, 31), + self::parseField($parts[3], 1, 12, [ + 'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4, + 'MAY' => 5, 'JUN' => 6, 'JUL' => 7, 'AUG' => 8, + 'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12, + ]), + self::parseField($parts[4], 0, 6, [ + 'SUN' => 0, 'MON' => 1, 'TUE' => 2, 'WED' => 3, + 'THU' => 4, 'FRI' => 5, 'SAT' => 6, + '7' => 0, + ]), + trim($parts[2]) === '*', + trim($parts[4]) === '*' + ); + } + + public function matches(DateTimeImmutable $utcDateTime, DateTimeZone $timezone): bool + { + $local = $utcDateTime->setTimezone($timezone); + $minute = (int) $local->format('i'); + $hour = (int) $local->format('G'); + $dayOfMonth = (int) $local->format('j'); + $month = (int) $local->format('n'); + $dayOfWeek = (int) $local->format('w'); + + if (!isset($this->minutes[$minute]) || !isset($this->hours[$hour]) || !isset($this->months[$month])) { + return false; + } + + $dayOfMonthMatch = isset($this->daysOfMonth[$dayOfMonth]); + $dayOfWeekMatch = isset($this->daysOfWeek[$dayOfWeek]); + if ($this->daysOfMonthWildcard && $this->daysOfWeekWildcard) { + $dayMatches = true; + } elseif ($this->daysOfMonthWildcard) { + $dayMatches = $dayOfWeekMatch; + } elseif ($this->daysOfWeekWildcard) { + $dayMatches = $dayOfMonthMatch; + } else { + $dayMatches = $dayOfMonthMatch || $dayOfWeekMatch; + } + + return $dayMatches; + } + + public function previousRun(DateTimeImmutable $utcDateTime, DateTimeZone $timezone, int $lookbackMinutes = 527040): ?DateTimeImmutable + { + $cursor = $this->floorToMinute($utcDateTime); + for ($i = 0; $i <= $lookbackMinutes; $i++) { + if ($this->matches($cursor, $timezone)) { + return $cursor; + } + + $cursor = $cursor->sub(new DateInterval('PT1M')); + } + + return null; + } + + public function nextRun(DateTimeImmutable $utcDateTime, DateTimeZone $timezone, int $lookaheadMinutes = 527040): ?DateTimeImmutable + { + $cursor = $this->floorToMinute($utcDateTime)->add(new DateInterval('PT1M')); + for ($i = 0; $i <= $lookaheadMinutes; $i++) { + if ($this->matches($cursor, $timezone)) { + return $cursor; + } + + $cursor = $cursor->add(new DateInterval('PT1M')); + } + + return null; + } + + /** + * @return array + */ + private static function parseField(string $field, int $min, int $max, array $aliases = []): array + { + $field = strtoupper(trim($field)); + if ($field === '*') { + $all = []; + for ($value = $min; $value <= $max; $value++) { + $all[$value] = true; + } + + return $all; + } + + $values = []; + foreach (explode(',', $field) as $segment) { + $segment = strtoupper(trim($segment)); + if ($segment === '') { + continue; + } + + $step = 1; + if (str_contains($segment, '/')) { + [$segment, $stepPart] = explode('/', $segment, 2); + if (!is_numeric($stepPart) || (int) $stepPart <= 0) { + throw new \InvalidArgumentException('Ungueltiger Cron-Step in Feld "' . $field . '".'); + } + + $step = (int) $stepPart; + } + + if ($segment === '*') { + $start = $min; + $end = $max; + } elseif (str_contains($segment, '-')) { + [$startPart, $endPart] = explode('-', $segment, 2); + $start = self::normalizePart($startPart, $aliases); + $end = self::normalizePart($endPart, $aliases); + } else { + $start = self::normalizePart($segment, $aliases); + $end = $start; + } + + if ($start < $min || $start > $max || $end < $min || $end > $max || $end < $start) { + throw new \InvalidArgumentException('Cron-Feld "' . $field . '" liegt ausserhalb des erlaubten Bereichs.'); + } + + for ($value = $start; $value <= $end; $value += $step) { + $values[$value] = true; + } + } + + if ($values === []) { + throw new \InvalidArgumentException('Cron-Feld "' . $field . '" ist leer.'); + } + + ksort($values); + + return $values; + } + + private static function normalizePart(string $part, array $aliases): int + { + $part = strtoupper(trim($part)); + if ($part === '') { + throw new \InvalidArgumentException('Leerer Cron-Wert.'); + } + + if (array_key_exists($part, $aliases)) { + return (int) $aliases[$part]; + } + + if (!is_numeric($part)) { + throw new \InvalidArgumentException('Ungueltiger Cron-Wert "' . $part . '".'); + } + + return (int) $part; + } + + private function floorToMinute(DateTimeImmutable $utcDateTime): DateTimeImmutable + { + return $utcDateTime->setTime( + (int) $utcDateTime->format('H'), + (int) $utcDateTime->format('i'), + 0 + ); + } +} diff --git a/src/App/CronManagementService.php b/src/App/CronManagementService.php new file mode 100644 index 00000000..e4a50229 --- /dev/null +++ b/src/App/CronManagementService.php @@ -0,0 +1,515 @@ + + */ + public function bootstrap(?string $baseUrlFallback = null): array + { + $settings = $this->loadSettings($baseUrlFallback); + $definitions = $this->definitions(); + $statuses = $this->statuses($settings, $definitions); + + return [ + 'settings' => $settings, + 'jobs' => $statuses, + ]; + } + + /** + * @param array $input + * @return array + */ + public function saveGlobal(array $input, ?string $baseUrlFallback = null): array + { + $current = $this->loadSettings($baseUrlFallback); + $current['base_url'] = $this->sanitizeBaseUrl((string) ($input['base_url'] ?? $current['base_url'])); + $current['default_timezone'] = $this->sanitizeTimezone((string) ($input['default_timezone'] ?? $current['default_timezone'])); + $current['updated_at'] = gmdate(DATE_ATOM); + $this->settingsStore()->write($current); + + return $current; + } + + /** + * @return array + */ + public function regenerateToken(?string $baseUrlFallback = null): array + { + $current = $this->loadSettings($baseUrlFallback); + $current['cron_token'] = bin2hex(random_bytes(24)); + $current['updated_at'] = gmdate(DATE_ATOM); + $this->settingsStore()->write($current); + + return $current; + } + + /** + * @param array $input + * @return array + */ + public function saveJob(string $jobKey, array $input, ?string $baseUrlFallback = null): array + { + $settings = $this->loadSettings($baseUrlFallback); + $definitions = $this->definitions(); + if (!isset($definitions[$jobKey])) { + throw new \RuntimeException('Cron-Job nicht gefunden.'); + } + + $definition = $definitions[$jobKey]; + $settings['jobs'][$jobKey] = [ + 'enabled' => !empty($input['enabled']), + 'cron_expression' => trim((string) ($input['cron_expression'] ?? $definition['default_cron'] ?? '0 * * * *')), + 'timezone' => $this->sanitizeTimezone((string) ($input['timezone'] ?? $definition['default_timezone'] ?? $settings['default_timezone'])), + ]; + $settings['updated_at'] = gmdate(DATE_ATOM); + $this->settingsStore()->write($settings); + + return $settings; + } + + /** + * @return array> + */ + public function runDue(?string $baseUrlFallback = null): array + { + $settings = $this->loadSettings($baseUrlFallback); + $definitions = $this->definitions(); + $statuses = $this->statuses($settings, $definitions); + $results = []; + + foreach ($statuses as $status) { + if (empty($status['enabled']) || empty($status['is_due']) || !empty($status['parse_error'])) { + continue; + } + + $results[] = $this->runJob( + $status['job_key'], + $settings, + $definitions, + $status['previous_due_at'] ?? null, + 'cron_runner' + ); + } + + return $results; + } + + /** + * @return array + */ + public function runNow(string $jobKey, ?string $baseUrlFallback = null): array + { + $settings = $this->loadSettings($baseUrlFallback); + $definitions = $this->definitions(); + + return $this->runJob($jobKey, $settings, $definitions, null, 'manual_test'); + } + + /** + * @return array> + */ + private function definitions(): array + { + $definitions = []; + foreach ($this->moduleRegistry->cronJobs() as $job) { + $jobKey = (string) ($job['job_key'] ?? ''); + if ($jobKey === '') { + continue; + } + + $definitions[$jobKey] = $job; + } + + ksort($definitions); + + return $definitions; + } + + /** + * @param array $settings + * @param array> $definitions + * @return array> + */ + private function statuses(array $settings, array $definitions): array + { + $states = $this->stateStore()->read(); + $nowUtc = new DateTimeImmutable('now', new DateTimeZone('UTC')); + $result = []; + + foreach ($definitions as $jobKey => $definition) { + $config = $this->jobConfig($jobKey, $definition, $settings); + $state = is_array($states[$jobKey] ?? null) ? $states[$jobKey] : []; + $timezone = $this->safeTimezone((string) ($config['timezone'] ?? $settings['default_timezone'])); + $expression = trim((string) ($config['cron_expression'] ?? '')); + $parseError = null; + $previousDueUtc = null; + $nextDueUtc = null; + $isDue = false; + $isLocked = $this->isLockActive($state, time()); + + try { + $cron = CronExpression::parse($expression); + if (!empty($config['enabled'])) { + $previousDueUtc = $cron->previousRun($nowUtc, $timezone); + $lastScheduledFor = $this->parseUtc((string) ($state['last_scheduled_for'] ?? '')); + $isDue = !$isLocked + && $previousDueUtc instanceof DateTimeImmutable + && ($lastScheduledFor === null || $previousDueUtc > $lastScheduledFor); + $nextDueUtc = $isDue ? $previousDueUtc : $cron->nextRun($nowUtc, $timezone); + } + } catch (\Throwable $exception) { + $parseError = $exception->getMessage(); + } + + $result[] = array_merge($definition, [ + 'job_key' => $jobKey, + 'config' => $config, + 'state' => $state, + 'state_local' => [ + 'last_started_at' => $this->formatLocal((string) ($state['last_started_at'] ?? ''), $timezone), + 'last_finished_at' => $this->formatLocal((string) ($state['last_finished_at'] ?? ''), $timezone), + 'last_success_at' => $this->formatLocal((string) ($state['last_success_at'] ?? ''), $timezone), + 'last_scheduled_for' => $this->formatLocal((string) ($state['last_scheduled_for'] ?? ''), $timezone), + ], + 'enabled' => !empty($config['enabled']), + 'cron_expression' => $expression, + 'timezone' => $timezone->getName(), + 'parse_error' => $parseError, + 'is_due' => $isDue, + 'is_locked' => $isLocked, + 'previous_due_at' => $previousDueUtc?->format('Y-m-d H:i:s'), + 'next_due_at' => $nextDueUtc?->format('Y-m-d H:i:s'), + 'previous_due_at_local' => $previousDueUtc?->setTimezone($timezone)->format('d.m.Y H:i'), + 'next_due_at_local' => $nextDueUtc?->setTimezone($timezone)->format('d.m.Y H:i'), + ]); + } + + return $result; + } + + /** + * @param array $settings + * @param array> $definitions + * @return array + */ + private function runJob(string $jobKey, array $settings, array $definitions, ?string $scheduledForUtc, string $trigger): array + { + if (!isset($definitions[$jobKey])) { + throw new \RuntimeException('Cron-Job nicht gefunden.'); + } + + $definition = $definitions[$jobKey]; + $config = $this->jobConfig($jobKey, $definition, $settings); + $lockMinutes = max(1, (int) ($definition['lock_minutes'] ?? 10)); + $state = $this->stateStore()->read(); + $currentState = is_array($state[$jobKey] ?? null) ? $state[$jobKey] : []; + if ($this->isLockActive($currentState, time())) { + return [ + 'job_key' => $jobKey, + 'ok' => false, + 'message' => 'Cron-Job ist aktuell gesperrt.', + ]; + } + + $startedAt = gmdate('Y-m-d H:i:s'); + $this->persistState($jobKey, [ + 'last_started_at' => $startedAt, + 'last_status' => 'running', + 'last_message' => $trigger === 'manual_test' ? 'Manueller Testlauf gestartet.' : 'Cron-Lauf gestartet.', + 'lock_until' => gmdate('Y-m-d H:i:s', time() + ($lockMinutes * 60)), + ]); + + try { + $url = $this->absoluteUrl((string) $settings['base_url'], (string) ($definition['endpoint_path'] ?? '')); + $payload = is_array($definition['payload'] ?? null) ? $definition['payload'] : []; + $payload['trigger_source'] ??= $trigger === 'manual_test' ? 'manual' : 'cron'; + $response = $this->httpRequest( + strtoupper((string) ($definition['method'] ?? 'POST')), + $url, + $payload, + (string) ($settings['cron_token'] ?? '') + ); + + $ok = $response['ok']; + $message = $response['message'] !== '' ? $response['message'] : ($ok ? 'Cron-Lauf erfolgreich.' : 'Cron-Lauf fehlgeschlagen.'); + $finishedAt = gmdate('Y-m-d H:i:s'); + + $persist = [ + 'last_finished_at' => $finishedAt, + 'last_status' => $ok ? 'success' : 'error', + 'last_message' => $message, + 'lock_until' => null, + ]; + if ($scheduledForUtc !== null) { + $persist['last_scheduled_for'] = $scheduledForUtc; + } + if ($ok) { + $persist['last_success_at'] = $finishedAt; + } + $this->persistState($jobKey, $persist); + + return [ + 'job_key' => $jobKey, + 'ok' => $ok, + 'message' => $message, + 'url' => $url, + ]; + } catch (\Throwable $exception) { + $finishedAt = gmdate('Y-m-d H:i:s'); + $persist = [ + 'last_finished_at' => $finishedAt, + 'last_status' => 'error', + 'last_message' => $exception->getMessage(), + 'lock_until' => null, + ]; + if ($scheduledForUtc !== null) { + $persist['last_scheduled_for'] = $scheduledForUtc; + } + $this->persistState($jobKey, $persist); + + return [ + 'job_key' => $jobKey, + 'ok' => false, + 'message' => $exception->getMessage(), + ]; + } + } + + /** + * @param array $settings + * @return array + */ + private function jobConfig(string $jobKey, array $definition, array $settings): array + { + $saved = is_array($settings['jobs'][$jobKey] ?? null) ? $settings['jobs'][$jobKey] : []; + + return [ + 'enabled' => array_key_exists('enabled', $saved) ? !empty($saved['enabled']) : (bool) ($definition['default_enabled'] ?? false), + 'cron_expression' => trim((string) ($saved['cron_expression'] ?? $definition['default_cron'] ?? '0 * * * *')) ?: '0 * * * *', + 'timezone' => $this->sanitizeTimezone((string) ($saved['timezone'] ?? $definition['default_timezone'] ?? $settings['default_timezone'])), + ]; + } + + /** + * @return array + */ + private function loadSettings(?string $baseUrlFallback): array + { + $stored = $this->settingsStore()->read(); + $baseUrl = $this->sanitizeBaseUrl((string) ($stored['base_url'] ?? $this->detectBaseUrl($baseUrlFallback))); + $defaultTimezone = $this->sanitizeTimezone((string) ($stored['default_timezone'] ?? (function_exists('nexus_cron_timezone_name') ? nexus_cron_timezone_name() : 'Europe/Berlin'))); + $token = trim((string) ($stored['cron_token'] ?? '')); + + return [ + 'base_url' => $baseUrl, + 'default_timezone' => $defaultTimezone, + 'cron_token' => $token !== '' ? $token : bin2hex(random_bytes(24)), + 'jobs' => is_array($stored['jobs'] ?? null) ? $stored['jobs'] : [], + 'updated_at' => (string) ($stored['updated_at'] ?? ''), + ]; + } + + private function detectBaseUrl(?string $fallback): string + { + $fallback = trim((string) $fallback); + if ($fallback !== '') { + return $fallback; + } + + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = trim((string) ($_SERVER['HTTP_HOST'] ?? '')); + + return $host !== '' ? $scheme . '://' . $host : ''; + } + + private function sanitizeBaseUrl(string $value): string + { + $value = trim($value); + return rtrim($value, '/'); + } + + private function sanitizeTimezone(string $value): string + { + $value = trim($value); + if ($value !== '' && in_array($value, DateTimeZone::listIdentifiers(), true)) { + return $value; + } + + if (function_exists('nexus_cron_timezone_name')) { + return nexus_cron_timezone_name(); + } + + return 'Europe/Berlin'; + } + + private function safeTimezone(string $value): DateTimeZone + { + try { + return new DateTimeZone($this->sanitizeTimezone($value)); + } catch (\Throwable) { + return new DateTimeZone('Europe/Berlin'); + } + } + + private function formatLocal(string $utcDateTime, DateTimeZone $timezone): ?string + { + $parsed = $this->parseUtc($utcDateTime); + return $parsed?->setTimezone($timezone)->format('d.m.Y H:i'); + } + + private function absoluteUrl(string $baseUrl, string $endpointPath): string + { + $baseUrl = rtrim(trim($baseUrl), '/'); + $endpointPath = '/' . ltrim(trim($endpointPath), '/'); + if ($baseUrl === '') { + throw new \RuntimeException('Keine Base-URL fuer Cron-Aufrufe konfiguriert.'); + } + + return $baseUrl . $endpointPath; + } + + /** + * @param array $payload + * @return array{ok: bool, message: string} + */ + private function httpRequest(string $method, string $url, array $payload, string $token): array + { + $headers = [ + 'Accept: application/json', + 'Content-Type: application/json', + ]; + if ($token !== '') { + $headers[] = 'X-Desktop-Cron-Token: ' . $token; + } + + $body = $method === 'GET' ? null : json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($method === 'GET' && $payload !== []) { + $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($payload, '', '&', PHP_QUERY_RFC3986); + } + + if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 120, + CURLOPT_CONNECTTIMEOUT => 15, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => $body, + ]); + $raw = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if (!is_string($raw)) { + throw new \RuntimeException($error !== '' ? $error : 'Cron-HTTP-Aufruf fehlgeschlagen.'); + } + + return $this->decodeHttpResponse($status, $raw); + } + + $context = stream_context_create([ + 'http' => [ + 'method' => $method, + 'header' => implode("\r\n", $headers), + 'content' => $body ?: '', + 'ignore_errors' => true, + 'timeout' => 120, + ], + ]); + $raw = @file_get_contents($url, false, $context); + $statusLine = is_array($http_response_header ?? null) ? (string) ($http_response_header[0] ?? '') : ''; + preg_match('/\s(\d{3})\s/', $statusLine, $matches); + $status = isset($matches[1]) ? (int) $matches[1] : 0; + if (!is_string($raw)) { + throw new \RuntimeException('Cron-HTTP-Aufruf fehlgeschlagen.'); + } + + return $this->decodeHttpResponse($status, $raw); + } + + /** + * @return array{ok: bool, message: string} + */ + private function decodeHttpResponse(int $status, string $raw): array + { + $decoded = json_decode($raw, true); + $message = ''; + if (is_array($decoded)) { + $message = trim((string) ($decoded['data']['message'] ?? $decoded['message'] ?? $decoded['error'] ?? '')); + } + + return [ + 'ok' => $status >= 200 && $status < 300, + 'message' => $message, + ]; + } + + /** + * @param array $values + */ + private function persistState(string $jobKey, array $values): void + { + $states = $this->stateStore()->read(); + $current = is_array($states[$jobKey] ?? null) ? $states[$jobKey] : []; + $states[$jobKey] = array_merge($current, $values, [ + 'updated_at' => gmdate('Y-m-d H:i:s'), + ]); + $this->stateStore()->write($states); + } + + /** + * @param array $state + */ + private function isLockActive(array $state, int $nowTs): bool + { + $lockUntil = trim((string) ($state['lock_until'] ?? '')); + if ($lockUntil === '') { + return false; + } + + $lockTs = strtotime($lockUntil); + return $lockTs !== false && $lockTs > $nowTs; + } + + private function parseUtc(string $value): ?DateTimeImmutable + { + $value = trim($value); + if ($value === '') { + return null; + } + + try { + return new DateTimeImmutable($value, new DateTimeZone('UTC')); + } catch (\Throwable) { + return null; + } + } + + private function settingsStore(): JsonStore + { + return new JsonStore($this->projectRoot . '/data/system/cron/settings.json'); + } + + private function stateStore(): JsonStore + { + return new JsonStore($this->projectRoot . '/data/system/cron/state.json'); + } +} diff --git a/src/App/LdapProvisioner.php b/src/App/LdapProvisioner.php index 5c888fc9..beaeb262 100644 --- a/src/App/LdapProvisioner.php +++ b/src/App/LdapProvisioner.php @@ -98,6 +98,7 @@ final class LdapProvisioner return []; } + $membershipMap = $this->loadGroupMembershipMap($link); $results = []; for ($index = 0; $index < (int) $entries['count']; $index++) { $entry = $entries[$index] ?? null; @@ -120,6 +121,7 @@ final class LdapProvisioner 'display_name' => $displayName, 'email' => $this->firstAttributeValue($entry, 'mail'), 'dn' => (string) ($entry['dn'] ?? ''), + 'group_dns' => $this->resolveUserGroupDns($entry, $username, $membershipMap), ]; } @@ -137,6 +139,101 @@ final class LdapProvisioner } } + /** + * @param resource|\LDAP\Connection $link + * @return array> + */ + private function loadGroupMembershipMap($link): array + { + $groups = array_values(array_filter(array_map( + static fn (array $group): string => trim((string) ($group['dn'] ?? '')), + (array) ($this->config['ldap']['available_groups'] ?? []) + ))); + if ($groups === []) { + return []; + } + + $attribute = (string) ($this->config['ldap']['group_member_attribute'] ?? 'memberUid'); + $map = []; + + foreach ($groups as $groupDn) { + $read = @ldap_read($link, $groupDn, '(objectClass=*)', [$attribute]); + if ($read === false) { + continue; + } + + $entries = ldap_get_entries($link, $read); + if (!is_array($entries) || (int) ($entries['count'] ?? 0) < 1 || !is_array($entries[0] ?? null)) { + continue; + } + + $values = $entries[0][strtolower($attribute)] ?? $entries[0][$attribute] ?? null; + if (!is_array($values)) { + continue; + } + + foreach ($values as $key => $value) { + if (!is_int($key)) { + continue; + } + + $normalized = trim((string) $value); + if ($normalized === '') { + continue; + } + + $lookupKeys = [$normalized]; + if (str_contains($normalized, ',')) { + $uidPrefix = strtok($normalized, ','); + if (is_string($uidPrefix) && str_starts_with(strtolower($uidPrefix), 'uid=')) { + $lookupKeys[] = substr($uidPrefix, 4); + } + } + + foreach ($lookupKeys as $lookupKey) { + $lookupKey = strtolower(trim((string) $lookupKey)); + if ($lookupKey === '') { + continue; + } + + $map[$lookupKey] ??= []; + $map[$lookupKey][] = $groupDn; + } + } + } + + foreach ($map as $key => $groupDns) { + $map[$key] = array_values(array_unique(array_filter(array_map('strval', $groupDns)))); + } + + return $map; + } + + /** + * @param array $entry + * @param array> $membershipMap + * @return array + */ + private function resolveUserGroupDns(array $entry, string $username, array $membershipMap): array + { + $mode = (string) ($this->config['ldap']['group_assignment_mode'] ?? 'group_memberuid'); + if ($mode === 'user_memberof') { + $memberOf = $entry['memberof'] ?? $entry['memberOf'] ?? []; + if (is_string($memberOf)) { + return $memberOf !== '' ? [$memberOf] : []; + } + + if (is_array($memberOf)) { + return array_values(array_unique(array_filter(array_map('strval', $memberOf)))); + } + } + + return array_values(array_unique(array_filter(array_map( + 'strval', + $membershipMap[strtolower(trim($username))] ?? [] + )))); + } + /** * @param array $attributes * @return array|null diff --git a/src/App/UserSelfManagementService.php b/src/App/UserSelfManagementService.php index bce06918..4675c3c0 100644 --- a/src/App/UserSelfManagementService.php +++ b/src/App/UserSelfManagementService.php @@ -105,7 +105,23 @@ final class UserSelfManagementService { $settings = $this->load($authUser, $visibleApps, $widgets, $availableSkins); $enabledAppIds = array_values(array_map('strval', (array) ($settings['apps']['enabled_ids'] ?? []))); + $enabledAppLookup = array_fill_keys($enabledAppIds, true); $activeWidgetIds = array_values(array_map('strval', (array) ($settings['widgets']['active_ids'] ?? []))); + $installableApps = array_values(array_filter( + $visibleApps, + static fn (array $app): bool => !array_key_exists('installable', $app) || (bool) $app['installable'] + )); + $availableWidgets = array_values(array_filter( + $widgets, + static function (array $widget) use ($enabledAppLookup): bool { + $sourceAppId = trim((string) ($widget['source_app_id'] ?? '')); + if ($sourceAppId === '') { + return true; + } + + return isset($enabledAppLookup[$sourceAppId]); + } + )); return [ 'preferences' => $settings, @@ -123,10 +139,11 @@ final class UserSelfManagementService 'title' => (string) ($app['title'] ?? ''), 'summary' => (string) ($app['summary'] ?? ''), 'module_name' => (string) ($app['module_name'] ?? ''), + 'app_scope' => (string) ($app['app_scope'] ?? 'module'), 'required' => in_array((string) ($app['app_id'] ?? ''), [self::APP_ID], true), 'enabled' => in_array((string) ($app['app_id'] ?? ''), $enabledAppIds, true), ], - $visibleApps + $installableApps ), 'widgets' => array_map( static fn (array $widget): array => [ @@ -135,8 +152,9 @@ final class UserSelfManagementService 'summary' => (string) ($widget['summary'] ?? ''), 'default_enabled' => (bool) ($widget['default_enabled'] ?? false), 'enabled' => in_array((string) ($widget['widget_id'] ?? ''), $activeWidgetIds, true), + 'source_app_id' => (string) ($widget['source_app_id'] ?? ''), ], - $widgets + $availableWidgets ), ], 'meta' => [ @@ -193,7 +211,9 @@ final class UserSelfManagementService private function normalizeSettings(array $settings, array $authUser, array $visibleApps, array $widgets, array $availableSkins): array { $visibleAppIds = array_values(array_filter(array_map( - static fn (array $app): string => (string) ($app['app_id'] ?? ''), + static fn (array $app): ?string => (!array_key_exists('installable', $app) || (bool) $app['installable']) + ? (string) ($app['app_id'] ?? '') + : null, $visibleApps ))); $defaultEnabledAppIds = array_values(array_filter(array_map( @@ -237,7 +257,18 @@ final class UserSelfManagementService )); $activeWidgetIds = $this->sanitizeSelection( array_merge($storedActiveWidgetIds, $newDefaultWidgetIds), - $allWidgetIds + array_values(array_filter($allWidgetIds, function (string $widgetId) use ($widgets, $enabledAppIds): bool { + foreach ($widgets as $widget) { + if ((string) ($widget['widget_id'] ?? '') !== $widgetId) { + continue; + } + + $sourceAppId = trim((string) ($widget['source_app_id'] ?? '')); + return $sourceAppId === '' || in_array($sourceAppId, $enabledAppIds, true); + } + + return false; + })) ); $profile = []; diff --git a/src/Desktop/AppVisibility.php b/src/Desktop/AppVisibility.php index 321460bf..e93c058e 100644 --- a/src/Desktop/AppVisibility.php +++ b/src/Desktop/AppVisibility.php @@ -48,11 +48,16 @@ final class AppVisibility return array_values(array_filter($apps, static function (array $app) use ($lookup, $mandatoryLookup): bool { $appId = (string) ($app['app_id'] ?? ''); + $installable = !array_key_exists('installable', $app) || (bool) $app['installable']; if ($appId === '') { return false; } + if (!$installable) { + return true; + } + return isset($lookup[$appId]) || isset($mandatoryLookup[$appId]); })); } diff --git a/src/Desktop/WidgetRegistry.php b/src/Desktop/WidgetRegistry.php index cc94b2d6..8d0cac29 100644 --- a/src/Desktop/WidgetRegistry.php +++ b/src/Desktop/WidgetRegistry.php @@ -9,11 +9,29 @@ final class WidgetRegistry /** @var array> */ private array $widgets; - public function __construct(string $configPath) + /** + * @param array> $additionalWidgets + */ + public function __construct(string $configPath, array $additionalWidgets = []) { /** @var array> $widgets */ $widgets = require $configPath; - $this->widgets = $widgets; + $lookup = []; + + foreach (array_merge($widgets, $additionalWidgets) as $widget) { + if (!is_array($widget)) { + continue; + } + + $widgetId = (string) ($widget['widget_id'] ?? ''); + if ($widgetId === '') { + continue; + } + + $lookup[$widgetId] = $widget; + } + + $this->widgets = array_values($lookup); } /** @@ -25,15 +43,38 @@ final class WidgetRegistry } /** - * @param array $widgetIds + * @param array $enabledAppIds * @return array> */ - public function active(array $widgetIds): array + public function availableForApps(array $enabledAppIds): array { - $lookup = array_fill_keys($widgetIds, true); + $enabledLookup = array_fill_keys($enabledAppIds, true); return array_values(array_filter( $this->widgets, + static function (array $widget) use ($enabledLookup): bool { + $sourceAppId = trim((string) ($widget['source_app_id'] ?? '')); + if ($sourceAppId === '') { + return true; + } + + return isset($enabledLookup[$sourceAppId]); + } + )); + } + + /** + * @param array $widgetIds + * @param array $enabledAppIds + * @return array> + */ + public function active(array $widgetIds, array $enabledAppIds = []): array + { + $lookup = array_fill_keys($widgetIds, true); + $available = $enabledAppIds === [] ? $this->widgets : $this->availableForApps($enabledAppIds); + + return array_values(array_filter( + $available, static fn (array $widget): bool => isset($lookup[$widget['widget_id'] ?? '']) )); } diff --git a/src/ModulesCore/LegacyCompat.php b/src/ModulesCore/LegacyCompat.php index 73566d5c..991379e8 100644 --- a/src/ModulesCore/LegacyCompat.php +++ b/src/ModulesCore/LegacyCompat.php @@ -436,6 +436,28 @@ namespace { } } + if (!function_exists('nexus_cron_timezone_name')) { + function nexus_cron_timezone_name(): string + { + $candidates = [ + $_COOKIE['desktop_timezone'] ?? null, + $_SESSION['desktop_timezone'] ?? null, + getenv('DESKTOP_CRON_TIMEZONE') ?: null, + getenv('DESKTOP_TIMEZONE') ?: null, + 'Europe/Berlin', + ]; + + foreach ($candidates as $candidate) { + $timezone = trim((string) $candidate); + if ($timezone !== '' && in_array($timezone, \DateTimeZone::listIdentifiers(), true)) { + return $timezone; + } + } + + return 'Europe/Berlin'; + } + } + if (!function_exists('auth_enabled')) { function auth_enabled(): bool { diff --git a/src/ModulesCore/ModuleRegistry.php b/src/ModulesCore/ModuleRegistry.php index c7cd1687..59619b5d 100644 --- a/src/ModulesCore/ModuleRegistry.php +++ b/src/ModulesCore/ModuleRegistry.php @@ -8,6 +8,12 @@ final class ModuleRegistry { /** @var array>|null */ private ?array $desktopApps = null; + /** @var array>|null */ + private ?array $widgets = null; + /** @var array>|null */ + private ?array $cronJobs = null; + /** @var array>|null */ + private ?array $manifests = null; public function __construct( private readonly string $modulesPath, @@ -26,6 +32,12 @@ final class ModuleRegistry $apps = []; foreach ($this->moduleDirectories() as $moduleName => $modulePath) { + $manifest = $this->manifest($moduleName, $modulePath); + $desktopMeta = is_array($manifest['desktop'] ?? null) ? $manifest['desktop'] : []; + if (array_key_exists('available', $desktopMeta) && !$desktopMeta['available']) { + continue; + } + $desktopConfigPath = $modulePath . '/desktop.php'; if (!is_file($desktopConfigPath)) { continue; @@ -36,7 +48,6 @@ final class ModuleRegistry continue; } - $manifest = $this->readJson($modulePath . '/module.json'); $desktopConfig['module_id'] = $moduleName; $desktopConfig['title'] ??= (string) ($manifest['title'] ?? ucfirst($moduleName)); $desktopConfig['summary'] ??= (string) ($manifest['description'] ?? ''); @@ -45,12 +56,67 @@ final class ModuleRegistry (array) ($manifest['auth']['groups'] ?? []) )); $desktopConfig['enabled_by_default'] ??= (bool) ($manifest['enabled_by_default'] ?? false); + $desktopConfig['app_scope'] ??= (string) ($manifest['app_scope'] ?? 'module'); + $desktopConfig['installable'] ??= array_key_exists('installable', $manifest) ? (bool) $manifest['installable'] : true; + $desktopConfig['show_on_desktop'] ??= !array_key_exists('show_on_desktop', $desktopMeta) || (bool) $desktopMeta['show_on_desktop']; + $desktopConfig['show_in_start_menu'] ??= !array_key_exists('show_in_start_menu', $desktopMeta) || (bool) $desktopMeta['show_in_start_menu']; + $desktopConfig['supports_widget'] ??= $this->moduleWidgetsForManifest($moduleName, $manifest, $desktopConfig) !== []; + $desktopConfig['supports_cron'] ??= $this->moduleCronJobsForManifest($moduleName, $manifest, $desktopConfig) !== []; $apps[] = $desktopConfig; } return $this->desktopApps = $apps; } + /** + * @return array> + */ + public function widgets(): array + { + if ($this->widgets !== null) { + return $this->widgets; + } + + $widgets = []; + foreach ($this->moduleDirectories() as $moduleName => $modulePath) { + $manifest = $this->manifest($moduleName, $modulePath); + foreach ($this->moduleWidgetsForManifest($moduleName, $manifest, null) as $widget) { + $widgets[] = $widget; + } + } + + return $this->widgets = $widgets; + } + + /** + * @return array> + */ + public function cronJobs(): array + { + if ($this->cronJobs !== null) { + return $this->cronJobs; + } + + $jobs = []; + foreach ($this->moduleDirectories() as $moduleName => $modulePath) { + $manifest = $this->manifest($moduleName, $modulePath); + $desktopConfig = null; + $desktopConfigPath = $modulePath . '/desktop.php'; + if (is_file($desktopConfigPath)) { + $loaded = require $desktopConfigPath; + if (is_array($loaded)) { + $desktopConfig = $loaded; + } + } + + foreach ($this->moduleCronJobsForManifest($moduleName, $manifest, $desktopConfig) as $job) { + $jobs[] = $job; + } + } + + return $this->cronJobs = $jobs; + } + public function modulePath(string $moduleName): ?string { $directories = $this->moduleDirectories(); @@ -84,6 +150,108 @@ final class ModuleRegistry return $directories; } + /** + * @return array + */ + private function manifest(string $moduleName, string $modulePath): array + { + if ($this->manifests === null) { + $this->manifests = []; + } + + if (!array_key_exists($moduleName, $this->manifests)) { + $this->manifests[$moduleName] = $this->readJson($modulePath . '/module.json'); + } + + return $this->manifests[$moduleName]; + } + + /** + * @param array $manifest + * @param array|null $desktopConfig + * @return array> + */ + private function moduleWidgetsForManifest(string $moduleName, array $manifest, ?array $desktopConfig): array + { + $appId = (string) (($desktopConfig['app_id'] ?? null) ?: ($manifest['app_id'] ?? $moduleName)); + $widgets = []; + + foreach ((array) ($manifest['widgets'] ?? []) as $widget) { + if (!is_array($widget)) { + continue; + } + + $widgetId = trim((string) ($widget['widget_id'] ?? '')); + $title = trim((string) ($widget['title'] ?? '')); + if ($widgetId === '' || $title === '') { + continue; + } + + $widgets[] = [ + 'widget_id' => $widgetId, + 'title' => $title, + 'icon' => (string) ($widget['icon'] ?? strtoupper(substr($appId, 0, 2))), + 'zone' => (string) ($widget['zone'] ?? 'sidebar'), + 'default_enabled' => (bool) ($widget['default_enabled'] ?? false), + 'supports_public_home' => (bool) ($widget['supports_public_home'] ?? false), + 'summary' => (string) ($widget['summary'] ?? ''), + 'content' => (string) ($widget['content'] ?? ''), + 'launch_app_id' => (string) ($widget['launch_app_id'] ?? $appId), + 'action_label' => (string) ($widget['action_label'] ?? 'Oeffnen'), + 'widget_type' => (string) ($widget['widget_type'] ?? 'default'), + 'status_api' => isset($widget['status_api']) ? (string) $widget['status_api'] : null, + 'refresh_api' => isset($widget['refresh_api']) ? (string) $widget['refresh_api'] : null, + 'source_app_id' => $appId, + 'source_module_id' => $moduleName, + 'actions' => is_array($widget['actions'] ?? null) ? array_values($widget['actions']) : [], + ]; + } + + return $widgets; + } + + /** + * @param array $manifest + * @param array|null $desktopConfig + * @return array> + */ + private function moduleCronJobsForManifest(string $moduleName, array $manifest, ?array $desktopConfig): array + { + $appId = (string) (($desktopConfig['app_id'] ?? null) ?: ($manifest['app_id'] ?? $moduleName)); + $jobs = []; + + foreach ((array) ($manifest['cron_jobs'] ?? []) as $job) { + if (!is_array($job)) { + continue; + } + + $jobId = trim((string) ($job['job_id'] ?? $job['name'] ?? '')); + $endpointPath = trim((string) ($job['endpoint_path'] ?? '')); + if ($jobId === '' || $endpointPath === '') { + continue; + } + + $jobs[] = [ + 'job_key' => $moduleName . '.' . $jobId, + 'job_id' => $jobId, + 'module_id' => $moduleName, + 'app_id' => $appId, + 'app_scope' => (string) ($manifest['app_scope'] ?? 'module'), + 'label' => trim((string) ($job['label'] ?? $jobId)), + 'description' => trim((string) ($job['description'] ?? '')), + 'endpoint_path' => $endpointPath, + 'method' => strtoupper(trim((string) ($job['method'] ?? 'POST'))) ?: 'POST', + 'payload' => is_array($job['payload'] ?? null) ? $job['payload'] : [], + 'default_enabled' => (bool) ($job['default_enabled'] ?? false), + 'default_cron' => trim((string) ($job['default_cron'] ?? '0 * * * *')) ?: '0 * * * *', + 'default_timezone' => trim((string) ($job['default_timezone'] ?? '')) ?: 'Europe/Berlin', + 'lock_minutes' => max(1, (int) ($job['lock_minutes'] ?? 10)), + ]; + } + + return $jobs; + } + /** * @return array */ diff --git a/temp/nexus-module-import/MODULE_DEVELOPMENT.md b/temp/nexus-module-import/MODULE_DEVELOPMENT.md index 708e8cb8..18b77697 100644 --- a/temp/nexus-module-import/MODULE_DEVELOPMENT.md +++ b/temp/nexus-module-import/MODULE_DEVELOPMENT.md @@ -146,6 +146,14 @@ Weitere anerkannte Setup-Bausteine: - Für Anzeige- und Formatierungslogik nach Möglichkeit `nexus_display_timezone_name()` nutzen. - Für Cron-Fallbacks nach Möglichkeit `nexus_cron_timezone_name()` nutzen. - Neue Module dürfen keine lokalen Zeitzonen direkt in Datenbank-Zeitspalten persistieren. +- Desktop-Faehigkeiten sollen im neuen Projekt zusaetzlich ueber Manifest-Metadaten beschrieben werden: + - `app_scope` + - `installable` + - `desktop.*` + - `widgets[]` + - `cron_jobs[]` +- Widget-Funktionen eines Moduls sollen nur ueber die gemeinsame Desktop-Widget-Infrastruktur laufen. +- Cron-Endpunkte eines Moduls sollen fuer die zentrale Cron-Verwaltung per HTTP-Endpunkt beschreibbar sein. 14) Pi-Control-Besonderheiten - Worker und Jobs unter `/tools/pi_control/`