Main update
All checks were successful
Deploy / deploy-staging (push) Successful in 25s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-24 02:24:39 +02:00
parent 5ff4c3fb2b
commit d121f74bd0
34 changed files with 2548 additions and 67 deletions

View File

@@ -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.',
],
];

View File

@@ -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',
],
];

View File

@@ -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.

View File

@@ -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

View File

@@ -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`.

View File

@@ -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

View File

@@ -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

View File

@@ -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([

View File

@@ -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." },

View File

@@ -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

View File

@@ -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

View File

@@ -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);
$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');

View File

@@ -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
View 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>

View File

@@ -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; ?>

View File

@@ -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';

View File

@@ -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);
$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';

View 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;
}
}

View 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);
};
})();

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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);
});
};

View File

@@ -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
View 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
View 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
);
}
}

View 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');
}
}

View File

@@ -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

View File

@@ -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 = [];

View File

@@ -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]);
}));
}

View File

@@ -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'] ?? ''])
));
}

View File

@@ -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
{

View File

@@ -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>
*/

View File

@@ -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/`