Main update
This commit is contained in:
@@ -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.',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -38,6 +38,8 @@ Verbindlich ist:
|
||||
- importierte Module mit eigener API ueber konsistente `public/api/<modul>/index.php`-Shims an die gemeinsame Desktop-Auth anbinden
|
||||
- importierte Module mit eigener Fensterseite ueber konsistente `public/apps/<modul>/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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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." },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
338
public/admin/cron/index.php
Normal file
338
public/admin/cron/index.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
|
||||
|
||||
use App\ConfigLoader;
|
||||
use App\CronManagementService;
|
||||
use App\KeycloakAuth;
|
||||
use ModulesCore\ModuleRegistry;
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak'));
|
||||
if (!$auth->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 = '<div class="window-app-shell cm-shell"><div class="window-app-frame cm-frame"><main class="window-app-main cm-main"><div class="window-app-panel cm-panel"><section class="window-app-card cm-card"><p class="cm-message is-error">Kein Zugriff auf Cron Tool.</p></section></div></main></div></div>';
|
||||
if ($isPartial) {
|
||||
echo $forbidden;
|
||||
return;
|
||||
}
|
||||
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cron Tool</title>
|
||||
<link rel="stylesheet" href="/assets/desktop/desktop.css">
|
||||
<link rel="stylesheet" href="/assets/apps/cron-management/app.css">
|
||||
</head>
|
||||
<body><?= $forbidden ?></body>
|
||||
</html><?php
|
||||
return;
|
||||
}
|
||||
|
||||
$csrfToken = (string) ($_SESSION['cron_management_token'] ?? '');
|
||||
if ($csrfToken === '') {
|
||||
$csrfToken = bin2hex(random_bytes(16));
|
||||
$_SESSION['cron_management_token'] = $csrfToken;
|
||||
}
|
||||
|
||||
$baseUrlFallback = ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http')
|
||||
. '://'
|
||||
. ($_SERVER['HTTP_HOST'] ?? 'localhost');
|
||||
|
||||
$service = new CronManagementService($projectRoot, new ModuleRegistry($projectRoot . '/modules'));
|
||||
$messages = [];
|
||||
$errors = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$postedToken = (string) ($_POST['csrf_token'] ?? '');
|
||||
|
||||
if ($postedToken === '' || !hash_equals($csrfToken, $postedToken)) {
|
||||
$errors[] = 'Die Formularpruefung ist fehlgeschlagen.';
|
||||
} else {
|
||||
$action = (string) ($_POST['action'] ?? '');
|
||||
|
||||
try {
|
||||
if ($action === 'save-global') {
|
||||
$service->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();
|
||||
?>
|
||||
<div id="cron-management-app" class="window-app-shell cm-shell">
|
||||
<div class="window-app-frame cm-frame">
|
||||
<aside class="window-app-sidebar cm-sidebar">
|
||||
<div class="window-app-brand cm-brand">
|
||||
<p class="window-app-kicker cm-kicker">Systemtool</p>
|
||||
<h1>Cron Tool</h1>
|
||||
<p class="window-app-copy cm-copy">Zentrale Verwaltung fuer modulweite Cron-Jobs, Zeitplaene und manuelle Testlaeufe.</p>
|
||||
</div>
|
||||
|
||||
<section class="window-app-card cm-card">
|
||||
<strong class="cm-side-title">Schnellstatus</strong>
|
||||
<div class="cm-side-metric">
|
||||
<span>Definierte Jobs</span>
|
||||
<strong><?= count($jobs) ?></strong>
|
||||
</div>
|
||||
<div class="cm-side-metric">
|
||||
<span>Faellige Jobs</span>
|
||||
<strong><?= count(array_filter($jobs, static fn (array $job): bool => !empty($job['is_due']))) ?></strong>
|
||||
</div>
|
||||
<div class="cm-side-metric">
|
||||
<span>Standard-Zeitzone</span>
|
||||
<strong><?= $h((string) ($settings['default_timezone'] ?? 'Europe/Berlin')) ?></strong>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main class="window-app-main cm-main">
|
||||
<div class="window-app-panel cm-panel">
|
||||
<section class="window-app-hero cm-hero">
|
||||
<div>
|
||||
<p class="window-app-kicker cm-kicker">Cron Registry</p>
|
||||
<h2 class="window-app-title">Globale Scheduler-Verwaltung</h2>
|
||||
<p class="window-app-copy cm-copy">Module hinterlegen ihre Cron-Endpunkte in der Manifest-Datei. Dieses Systemtool sammelt sie automatisch und verwaltet Zeitplaene zentral.</p>
|
||||
</div>
|
||||
<div class="window-app-pill-row cm-pill-row">
|
||||
<span class="window-app-pill">Base URL: <?= $h((string) ($settings['base_url'] ?? '')) ?></span>
|
||||
<span class="window-app-pill">Token aktiv</span>
|
||||
<span class="window-app-pill">Admin only</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<?php if ($messages !== []): ?>
|
||||
<section class="window-app-card cm-card">
|
||||
<div class="cm-message is-success">
|
||||
<strong>Ergebnis</strong>
|
||||
<ul class="cm-list">
|
||||
<?php foreach ($messages as $message): ?>
|
||||
<li><?= $h($message) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($errors !== []): ?>
|
||||
<section class="window-app-card cm-card">
|
||||
<div class="cm-message is-error">
|
||||
<strong>Bitte pruefen</strong>
|
||||
<ul class="cm-list">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<li><?= $h($error) ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<section class="window-app-card cm-card">
|
||||
<div class="cm-section-head">
|
||||
<div>
|
||||
<p class="window-app-kicker cm-kicker">Global</p>
|
||||
<h3 class="cm-section-title">Runner-Einstellungen</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="cm-form cm-form--global" method="post" action="/admin/cron/">
|
||||
<input type="hidden" name="csrf_token" value="<?= $h($csrfToken) ?>">
|
||||
<input type="hidden" name="action" value="save-global">
|
||||
<label class="cm-field">
|
||||
<span>Base URL fuer Cron-Aufrufe</span>
|
||||
<input type="text" name="base_url" value="<?= $h((string) ($settings['base_url'] ?? '')) ?>" placeholder="https://staging.desktop.kusche.berlin">
|
||||
</label>
|
||||
<label class="cm-field">
|
||||
<span>Standard-Zeitzone</span>
|
||||
<input type="text" name="default_timezone" value="<?= $h((string) ($settings['default_timezone'] ?? 'Europe/Berlin')) ?>" list="timezone-options">
|
||||
</label>
|
||||
<div class="cm-token-box">
|
||||
<strong>Cron-Token</strong>
|
||||
<code><?= $h((string) ($settings['cron_token'] ?? '')) ?></code>
|
||||
<p>Der Token wird fuer interne Cron-Aufrufe per `X-Desktop-Cron-Token` genutzt.</p>
|
||||
</div>
|
||||
<div class="cm-actions">
|
||||
<button class="window-app-nav-button cm-primary" type="submit">Globale Einstellungen speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form class="cm-inline-form" method="post" action="/admin/cron/">
|
||||
<input type="hidden" name="csrf_token" value="<?= $h($csrfToken) ?>">
|
||||
<input type="hidden" name="action" value="regenerate-token">
|
||||
<button class="window-app-nav-button cm-secondary" type="submit">Cron-Token neu erzeugen</button>
|
||||
</form>
|
||||
|
||||
<form class="cm-inline-form" method="post" action="/admin/cron/">
|
||||
<input type="hidden" name="csrf_token" value="<?= $h($csrfToken) ?>">
|
||||
<input type="hidden" name="action" value="run-due">
|
||||
<button class="window-app-nav-button cm-secondary" type="submit">Faellige Jobs jetzt ausfuehren</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="window-app-card cm-card">
|
||||
<div class="cm-section-head">
|
||||
<div>
|
||||
<p class="window-app-kicker cm-kicker">Jobs</p>
|
||||
<h3 class="cm-section-title">Erkannte Modul-Crons</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cm-job-list">
|
||||
<?php foreach ($jobs as $job): ?>
|
||||
<article class="window-app-card cm-job-card">
|
||||
<div class="cm-job-head">
|
||||
<div>
|
||||
<strong><?= $h((string) ($job['label'] ?? $job['job_key'] ?? 'Cron-Job')) ?></strong>
|
||||
<p><?= $h((string) ($job['description'] ?? '')) ?></p>
|
||||
</div>
|
||||
<div class="cm-job-meta">
|
||||
<span class="window-app-pill"><?= $h((string) ($job['module_id'] ?? 'module')) ?></span>
|
||||
<span class="window-app-pill"><?= $h((string) ($job['method'] ?? 'POST')) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cm-status-grid">
|
||||
<div><span>Status</span><strong><?= $h((string) (($job['state']['last_status'] ?? '') ?: ($job['enabled'] ? 'aktiv' : 'inaktiv'))) ?></strong></div>
|
||||
<div><span>Naechster Lauf</span><strong><?= $h((string) (($job['next_due_at_local'] ?? '') ?: 'n/a')) ?></strong></div>
|
||||
<div><span>Letzter Erfolg</span><strong><?= $h((string) (($job['state_local']['last_success_at'] ?? '') ?: 'n/a')) ?></strong></div>
|
||||
<div><span>Endpoint</span><strong><?= $h((string) ($job['endpoint_path'] ?? '')) ?></strong></div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($job['parse_error'])): ?>
|
||||
<p class="cm-parse-error">Cron-Fehler: <?= $h((string) $job['parse_error']) ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($job['state']['last_message'])): ?>
|
||||
<p class="cm-job-message"><?= $h((string) $job['state']['last_message']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<form class="cm-form" method="post" action="/admin/cron/">
|
||||
<input type="hidden" name="csrf_token" value="<?= $h($csrfToken) ?>">
|
||||
<input type="hidden" name="action" value="save-job">
|
||||
<input type="hidden" name="job_key" value="<?= $h((string) ($job['job_key'] ?? '')) ?>">
|
||||
<label class="cm-check">
|
||||
<input type="checkbox" name="enabled" value="1" <?= !empty($job['enabled']) ? 'checked' : '' ?>>
|
||||
<span>Aktiv</span>
|
||||
</label>
|
||||
<label class="cm-field">
|
||||
<span>Cron-Ausdruck</span>
|
||||
<input type="text" name="cron_expression" value="<?= $h((string) ($job['cron_expression'] ?? '0 * * * *')) ?>">
|
||||
</label>
|
||||
<label class="cm-field">
|
||||
<span>Zeitzone</span>
|
||||
<input type="text" name="timezone" value="<?= $h((string) ($job['timezone'] ?? ($settings['default_timezone'] ?? 'Europe/Berlin'))) ?>" list="timezone-options">
|
||||
</label>
|
||||
<div class="cm-actions">
|
||||
<button class="window-app-nav-button cm-primary" type="submit">Zeitplan speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form class="cm-inline-form" method="post" action="/admin/cron/">
|
||||
<input type="hidden" name="csrf_token" value="<?= $h($csrfToken) ?>">
|
||||
<input type="hidden" name="action" value="run-now">
|
||||
<input type="hidden" name="job_key" value="<?= $h((string) ($job['job_key'] ?? '')) ?>">
|
||||
<button class="window-app-nav-button cm-secondary" type="submit">Jetzt ausfuehren</button>
|
||||
</form>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($jobs === []): ?>
|
||||
<div class="cm-empty">Aktuell wurden noch keine Cron-Jobs aus Modulen registriert.</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<datalist id="timezone-options">
|
||||
<?php foreach (DateTimeZone::listIdentifiers() as $timezone): ?>
|
||||
<option value="<?= $h($timezone) ?>"></option>
|
||||
<?php endforeach; ?>
|
||||
</datalist>
|
||||
<?php
|
||||
$pageContent = (string) ob_get_clean();
|
||||
|
||||
if ($isPartial) {
|
||||
echo $pageContent;
|
||||
return;
|
||||
}
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cron Tool</title>
|
||||
<link rel="stylesheet" href="/assets/desktop/desktop.css">
|
||||
<link rel="stylesheet" href="/assets/apps/cron-management/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<?= $pageContent ?>
|
||||
<script src="/assets/apps/cron-management/app.js"></script>
|
||||
<script>
|
||||
window.initCronManagementApp?.(document.getElementById('cron-management-app'), { standalone: true });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
@@ -350,11 +359,16 @@ ob_start();
|
||||
<legend>Gruppen fuer Aktivierung</legend>
|
||||
<div class="um-checkbox-grid">
|
||||
<?php foreach ($availableGroups as $group): ?>
|
||||
<?php
|
||||
$groupDn = (string) ($group['dn'] ?? '');
|
||||
$selectedGroupDns = array_values(array_map('strval', (array) ($selectedExistingUser['group_dns'] ?? [])));
|
||||
$isChecked = in_array($groupDn, $selectedGroupDns, true);
|
||||
?>
|
||||
<label class="window-app-item um-checkbox-item">
|
||||
<input type="checkbox" name="manual_group_dns[]" value="<?= $h((string) ($group['dn'] ?? '')) ?>">
|
||||
<input type="checkbox" name="manual_group_dns[]" value="<?= $h($groupDn) ?>" <?= $isChecked ? 'checked' : '' ?>>
|
||||
<span class="window-app-item-main">
|
||||
<strong><?= $h((string) ($group['label'] ?? '')) ?></strong>
|
||||
<p><?= $h((string) ($group['dn'] ?? '')) ?></p>
|
||||
<p><?= $h($groupDn) ?></p>
|
||||
</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
186
public/assets/apps/cron-management/app.css
Normal file
186
public/assets/apps/cron-management/app.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
103
public/assets/apps/cron-management/app.js
Normal file
103
public/assets/apps/cron-management/app.js
Normal file
@@ -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 = `
|
||||
<div class="window-app-shell cm-shell">
|
||||
<div class="window-app-frame cm-frame">
|
||||
<main class="window-app-main cm-main">
|
||||
<div class="window-app-panel cm-panel">
|
||||
<section class="window-app-card cm-card">
|
||||
<p class="cm-message is-error">${String(message || 'Cron Tool konnte nicht geladen werden.')}</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? `<button class="widget-card-action widget-card-action--secondary" type="button" data-app-id="${escapeHtml(widget.launch_app_id)}">${escapeHtml(widget.action_label || 'Oeffnen')}</button>` : ''}
|
||||
</div>
|
||||
`
|
||||
: widget.widget_type === 'action-panel'
|
||||
? `
|
||||
<div class="widget-card-status" data-widget-status>${widget.content || 'Status wird geladen ...'}</div>
|
||||
<p class="widget-card-message" data-widget-message>${widget.summary || ''}</p>
|
||||
<div class="widget-card-actions">
|
||||
${(Array.isArray(widget.actions) ? widget.actions : []).map((action) => `
|
||||
<button
|
||||
class="widget-card-action${action?.variant === 'secondary' ? ' widget-card-action--secondary' : ''}"
|
||||
type="button"
|
||||
data-widget-action="${escapeHtml(String(action?.action_id || ''))}"
|
||||
>${escapeHtml(String(action?.label || 'Aktion'))}</button>
|
||||
`).join('')}
|
||||
${widget.launch_app_id ? `<button class="widget-card-action widget-card-action--secondary" type="button" data-app-id="${escapeHtml(widget.launch_app_id)}">${escapeHtml(widget.action_label || 'Oeffnen')}</button>` : ''}
|
||||
</div>
|
||||
`
|
||||
: `<p>${widget.content || ''}</p>${actionMarkup}`;
|
||||
card.innerHTML = `
|
||||
<div class="widget-card-header">
|
||||
@@ -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,7 +1809,38 @@ if (payloadNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
payload.apps.forEach((app) => {
|
||||
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;
|
||||
});
|
||||
|
||||
if (apps.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const section = document.createElement('section');
|
||||
section.className = 'start-menu-group';
|
||||
section.innerHTML = `
|
||||
<header class="start-menu-group-header">
|
||||
<strong>${group.label}</strong>
|
||||
<small>${group.description}</small>
|
||||
</header>
|
||||
`;
|
||||
|
||||
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';
|
||||
@@ -1722,7 +1856,11 @@ if (payloadNode) {
|
||||
openApp(app);
|
||||
closeStartMenu();
|
||||
});
|
||||
startMenuApps.appendChild(button);
|
||||
list.appendChild(button);
|
||||
});
|
||||
|
||||
section.appendChild(list);
|
||||
startMenuApps.appendChild(section);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
52
src/App/CronAuth.php
Normal file
52
src/App/CronAuth.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class CronAuth
|
||||
{
|
||||
public static function isAuthorizedRequest(string $projectRoot): bool
|
||||
{
|
||||
$configured = self::configuredToken($projectRoot);
|
||||
$provided = self::providedToken();
|
||||
|
||||
return $configured !== null
|
||||
&& $provided !== null
|
||||
&& hash_equals($configured, $provided);
|
||||
}
|
||||
|
||||
public static function configuredToken(string $projectRoot): ?string
|
||||
{
|
||||
$settings = self::settingsStore($projectRoot)->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');
|
||||
}
|
||||
}
|
||||
217
src/App/CronExpression.php
Normal file
217
src/App/CronExpression.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
|
||||
final class CronExpression
|
||||
{
|
||||
/** @var array<int, true> */
|
||||
private array $minutes;
|
||||
/** @var array<int, true> */
|
||||
private array $hours;
|
||||
/** @var array<int, true> */
|
||||
private array $daysOfMonth;
|
||||
/** @var array<int, true> */
|
||||
private array $months;
|
||||
/** @var array<int, true> */
|
||||
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<int, true>
|
||||
*/
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
515
src/App/CronManagementService.php
Normal file
515
src/App/CronManagementService.php
Normal file
@@ -0,0 +1,515 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use ModulesCore\ModuleRegistry;
|
||||
|
||||
final class CronManagementService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $projectRoot,
|
||||
private readonly ModuleRegistry $moduleRegistry,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed> $settings
|
||||
* @param array<string, array<string, mixed>> $definitions
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed> $settings
|
||||
* @param array<string, array<string, mixed>> $definitions
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $settings
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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<string, mixed> $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<string, mixed> $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');
|
||||
}
|
||||
}
|
||||
@@ -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<string, array<int, string>>
|
||||
*/
|
||||
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<string, mixed> $entry
|
||||
* @param array<string, array<int, string>> $membershipMap
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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<int, string> $attributes
|
||||
* @return array<string, mixed>|null
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -9,11 +9,29 @@ final class WidgetRegistry
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
private array $widgets;
|
||||
|
||||
public function __construct(string $configPath)
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $additionalWidgets
|
||||
*/
|
||||
public function __construct(string $configPath, array $additionalWidgets = [])
|
||||
{
|
||||
/** @var array<int, array<string, mixed>> $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<int, string> $widgetIds
|
||||
* @param array<int, string> $enabledAppIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, string> $widgetIds
|
||||
* @param array<int, string> $enabledAppIds
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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'] ?? ''])
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -8,6 +8,12 @@ final class ModuleRegistry
|
||||
{
|
||||
/** @var array<int, array<string, mixed>>|null */
|
||||
private ?array $desktopApps = null;
|
||||
/** @var array<int, array<string, mixed>>|null */
|
||||
private ?array $widgets = null;
|
||||
/** @var array<int, array<string, mixed>>|null */
|
||||
private ?array $cronJobs = null;
|
||||
/** @var array<string, array<string, mixed>>|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<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $manifest
|
||||
* @param array<string, mixed>|null $desktopConfig
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed> $manifest
|
||||
* @param array<string, mixed>|null $desktopConfig
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
|
||||
@@ -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/`
|
||||
|
||||
Reference in New Issue
Block a user