Main update
This commit is contained in:
@@ -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 = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user