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

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