$authUser * @param array> $visibleApps * @param array> $widgets * @param array $availableSkins * @return array */ public function load(array $authUser, array $visibleApps, array $widgets, array $availableSkins): array { return $this->normalizeSettings( $this->readRawSettings($authUser), $authUser, $visibleApps, $widgets, $availableSkins ); } /** * @param array $patch * @param array $authUser * @param array> $visibleApps * @param array> $widgets * @param array $availableSkins * @return array */ public function save(array $patch, array $authUser, array $visibleApps, array $widgets, array $availableSkins): array { $current = $this->load($authUser, $visibleApps, $widgets, $availableSkins); $merged = $this->mergeRecursive($current, $patch); $normalized = $this->normalizeSettings($merged, $authUser, $visibleApps, $widgets, $availableSkins); $normalized['updated_at'] = gmdate(DATE_ATOM); $this->writeRawSettings($normalized, $authUser); return $normalized; } /** * @param array $authUser * @param array> $visibleApps * @param array> $widgets * @param array $availableSkins * @return array{skin: string, settings: array} */ public function resolveActiveSkin(?string $requestedSkin, array $authUser, array $visibleApps, array $widgets, array $availableSkins): array { $settings = $this->load($authUser, $visibleApps, $widgets, $availableSkins); $storedSkin = (string) (($settings['desktop']['active_skin'] ?? '') ?: $this->defaultSkin()); $resolvedSkin = SkinResolver::resolve($requestedSkin !== null && $requestedSkin !== '' ? $requestedSkin : $storedSkin); if ($resolvedSkin !== $storedSkin) { $settings = $this->save( ['desktop' => ['active_skin' => $resolvedSkin]], $authUser, $visibleApps, $widgets, $availableSkins ); } return [ 'skin' => $resolvedSkin, 'settings' => $settings, ]; } /** * @param array $authUser * @param array> $visibleApps * @param array> $widgets * @param array $availableSkins * @return array */ public function buildBootstrapPayload(array $authUser, array $visibleApps, array $widgets, array $availableSkins): array { $settings = $this->load($authUser, $visibleApps, $widgets, $availableSkins); $enabledAppIds = array_values(array_map('strval', (array) ($settings['apps']['enabled_ids'] ?? []))); $activeWidgetIds = array_values(array_map('strval', (array) ($settings['widgets']['active_ids'] ?? []))); return [ 'preferences' => $settings, 'options' => [ 'skins' => array_map( static fn (string $skin): array => [ 'skin_id' => $skin, 'label' => (string) (SkinResolver::profile($skin)['label'] ?? ucfirst($skin)), ], $availableSkins ), 'apps' => array_map( static fn (array $app): array => [ 'app_id' => (string) ($app['app_id'] ?? ''), 'title' => (string) ($app['title'] ?? ''), 'summary' => (string) ($app['summary'] ?? ''), 'module_name' => (string) ($app['module_name'] ?? ''), 'required' => in_array((string) ($app['app_id'] ?? ''), [self::APP_ID], true), 'enabled' => in_array((string) ($app['app_id'] ?? ''), $enabledAppIds, true), ], $visibleApps ), 'widgets' => array_map( static fn (array $widget): array => [ 'widget_id' => (string) ($widget['widget_id'] ?? ''), 'title' => (string) ($widget['title'] ?? ''), 'summary' => (string) ($widget['summary'] ?? ''), 'default_enabled' => (bool) ($widget['default_enabled'] ?? false), 'enabled' => in_array((string) ($widget['widget_id'] ?? ''), $activeWidgetIds, true), ], $widgets ), ], 'meta' => [ 'storage_scope' => $this->storageScope($authUser), 'authenticated' => ($authUser['sub'] ?? '') !== '' || ($authUser['username'] ?? '') !== '', 'sync_mode' => $this->repository()->available() ? 'base-db-user-data' : 'local-fallback', 'sync_targets' => [ 'ldap' => 'planned', 'keycloak' => 'planned', ], ], ]; } /** * @param array $authUser */ public function storageScope(array $authUser): string { $rawScope = (string) ($authUser['username'] ?? $authUser['sub'] ?? 'guest'); $sanitized = preg_replace('/[^a-z0-9._-]+/i', '-', strtolower($rawScope)); return $sanitized !== null && $sanitized !== '' ? $sanitized : 'guest'; } private function fileStore(string $scope): JsonStore { return new JsonStore($this->projectRoot . '/data/user-self-management/users/' . $scope . '.json'); } private function repository(): DesktopUserPreferenceRepository { static $repository = null; if ($repository instanceof DesktopUserPreferenceRepository) { return $repository; } $repository = new DesktopUserPreferenceRepository( ConfigLoader::load($this->projectRoot, 'base_db') ); return $repository; } /** * @param array $settings * @param array $authUser * @param array> $visibleApps * @param array> $widgets * @param array $availableSkins * @return array */ 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'] ?? ''), $visibleApps ))); $defaultWidgetIds = array_values(array_filter(array_map( static fn (array $widget): ?string => ($widget['default_enabled'] ?? false) ? (string) ($widget['widget_id'] ?? '') : null, $widgets ))); $allWidgetIds = array_values(array_filter(array_map( static fn (array $widget): string => (string) ($widget['widget_id'] ?? ''), $widgets ))); $enabledAppIds = $this->sanitizeSelection( $settings['apps']['enabled_ids'] ?? $visibleAppIds, $visibleAppIds, [self::APP_ID] ); $activeWidgetIds = $this->sanitizeSelection( $settings['widgets']['active_ids'] ?? $defaultWidgetIds, $allWidgetIds ); $profile = []; foreach (self::PROFILE_FIELDS as $field) { $fallback = match ($field) { 'name' => (string) ($authUser['name'] ?? ''), 'email' => (string) ($authUser['email'] ?? ''), default => '', }; $profile[$field] = $this->limitString( (string) (($settings['profile'][$field] ?? '') ?: $fallback), $field === 'notes' ? 2000 : 255 ); } return [ 'schema_version' => 1, 'updated_at' => (string) ($settings['updated_at'] ?? ''), 'profile' => $profile, 'desktop' => [ 'active_skin' => $this->sanitizeSkin( (string) ($settings['desktop']['active_skin'] ?? $this->defaultSkin()), $availableSkins ), ], 'apps' => [ 'enabled_ids' => $enabledAppIds, ], 'widgets' => [ 'active_ids' => $activeWidgetIds, ], 'integration' => [ 'ldap_write_status' => 'planned', 'keycloak_write_status' => 'planned', ], ]; } /** * @param array $authUser * @return array */ private function readRawSettings(array $authUser): array { $scope = $this->storageScope($authUser); $repository = $this->repository(); if ($repository->available()) { $stored = $repository->read($scope); if (is_array($stored)) { return $stored; } $legacy = $this->fileStore($scope)->read(); if ($legacy !== []) { $repository->write($scope, $legacy, $authUser); } return $legacy; } return $this->fileStore($scope)->read(); } /** * @param array $settings * @param array $authUser */ private function writeRawSettings(array $settings, array $authUser): void { $scope = $this->storageScope($authUser); $repository = $this->repository(); if ($repository->available()) { $repository->write($scope, $settings, $authUser); return; } $this->fileStore($scope)->write($settings); } /** * @param mixed $selected * @param array $allowedIds * @param array $mandatoryIds * @return array */ private function sanitizeSelection(mixed $selected, array $allowedIds, array $mandatoryIds = []): array { $allowedLookup = array_fill_keys($allowedIds, true); $result = []; foreach ((array) $selected as $id) { $stringId = (string) $id; if ($stringId !== '' && isset($allowedLookup[$stringId])) { $result[$stringId] = true; } } foreach ($mandatoryIds as $mandatoryId) { if ($mandatoryId !== '' && isset($allowedLookup[$mandatoryId])) { $result[$mandatoryId] = true; } } return array_keys($result); } /** * @param array $availableSkins */ private function sanitizeSkin(string $skin, array $availableSkins): string { $resolved = SkinResolver::resolve($skin); return in_array($resolved, $availableSkins, true) ? $resolved : $this->defaultSkin(); } private function defaultSkin(): string { return SkinResolver::resolve(null); } /** * @param array $base * @param array $patch * @return array */ private function mergeRecursive(array $base, array $patch): array { foreach ($patch as $key => $value) { if (is_array($value) && isset($base[$key]) && is_array($base[$key]) && !$this->isList($value)) { /** @var array $baseItem */ $baseItem = $base[$key]; /** @var array $patchItem */ $patchItem = $value; $base[$key] = $this->mergeRecursive($baseItem, $patchItem); continue; } $base[$key] = $value; } return $base; } /** * @param array $value */ private function isList(array $value): bool { if ($value === []) { return true; } return array_keys($value) === range(0, count($value) - 1); } private function limitString(string $value, int $maxLength): string { $trimmed = trim($value); if (function_exists('mb_substr')) { return mb_substr($trimmed, 0, $maxLength); } return substr($trimmed, 0, $maxLength); } }