diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 859186ad..84b1e5e0 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -54,6 +54,7 @@ jobs: --exclude=".git/***" \ --exclude=".gitea/***" \ --exclude=".ci_config_deploy/***" \ + --exclude="data/***" \ --exclude="docs/***" \ --exclude="temp/***" \ --exclude="CopyToKeycloak/***" \ @@ -136,6 +137,7 @@ jobs: --exclude=".git/***" \ --exclude=".gitea/***" \ --exclude=".ci_config_deploy/***" \ + --exclude="data/***" \ --exclude="docs/***" \ --exclude="temp/***" \ --exclude="CopyToKeycloak/***" \ diff --git a/config/base_db.php b/config/base_db.php new file mode 100644 index 00000000..350cfbf7 --- /dev/null +++ b/config/base_db.php @@ -0,0 +1,8 @@ + false, + 'db' => [], +]; diff --git a/config/prod/base_db.php b/config/prod/base_db.php new file mode 100644 index 00000000..bda9baa2 --- /dev/null +++ b/config/prod/base_db.php @@ -0,0 +1,8 @@ + true, + 'db' => require __DIR__ . '/db_settings_basic.php', +]; diff --git a/config/prod/db_settings_basic.php b/config/prod/db_settings_basic.php new file mode 100644 index 00000000..23c2fad6 --- /dev/null +++ b/config/prod/db_settings_basic.php @@ -0,0 +1,53 @@ + 'pgsql', + 'host' => 'db_nexus', + 'port' => 5432, + 'dbname' => 'nexus', + 'connect_timeout' => 5, + 'schema' => 'public', + 'user' => 'nexusbasis', + 'password' => 'j4v6YV6tcszzKOf3YBTV', + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], +]; + +$mysql = [ + 'driver' => 'mysql', + 'host' => 'localhost', + 'port' => 3306, + 'dbname' => 'd0453540', + 'charset' => 'utf8mb4', + 'user' => 'd0453540', + 'password' => 'P6jGRrSaX8QSiBMEJBL7', + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ], +]; + +$sqlite = [ + 'driver' => 'sqlite', + 'path' => __DIR__ . '/../../var/app.sqlite', + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], +]; + +return match ($driver) { + 'pgsql' => $pgsql, + 'mysql' => $mysql, + 'sqlite' => $sqlite, + default => throw new RuntimeException('Unsupported DB driver in db_settings_basic.php: ' . $driver), +}; diff --git a/config/staging/base_db.php b/config/staging/base_db.php new file mode 100644 index 00000000..bda9baa2 --- /dev/null +++ b/config/staging/base_db.php @@ -0,0 +1,8 @@ + true, + 'db' => require __DIR__ . '/db_settings_basic.php', +]; diff --git a/config/staging/db_settings_basic.php b/config/staging/db_settings_basic.php new file mode 100644 index 00000000..60ce99ae --- /dev/null +++ b/config/staging/db_settings_basic.php @@ -0,0 +1,53 @@ + 'pgsql', + 'host' => 'staging_db_nexus', + 'port' => 5432, + 'dbname' => 'nexus_staging', + 'connect_timeout' => 5, + 'schema' => 'public', + 'user' => 'nexusbasisstg', + 'password' => '8HHtFt9ON6RkmwIS0c7U', + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], +]; + +$mysql = [ + 'driver' => 'mysql', + 'host' => 'localhost', + 'port' => 3306, + 'dbname' => 'd0453540', + 'charset' => 'utf8mb4', + 'user' => 'd0453540', + 'password' => 'P6jGRrSaX8QSiBMEJBL7', + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ], +]; + +$sqlite = [ + 'driver' => 'sqlite', + 'path' => __DIR__ . '/../../var/app.sqlite', + 'options' => [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ], +]; + +return match ($driver) { + 'pgsql' => $pgsql, + 'mysql' => $mysql, + 'sqlite' => $sqlite, + default => throw new RuntimeException('Unsupported DB driver in db_settings_basic.php: ' . $driver), +}; diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 03ce8ec8..b60a9001 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -55,6 +55,7 @@ Aktuell gelten folgende Regeln: - Standardfelder wie Name, E-Mail, Telefon, Titel und Ort koennen fuer LDAP vorbereitet oder synchronisiert werden. - `Geburtsdatum` bleibt derzeit lokal gespeichert. - nicht jedes Profilfeld wird automatisch in LDAP geschrieben. +- Desktop-bezogene Nutzereinstellungen werden in einer eigenen Datenbanktabelle gespeichert. ## Desktop Type und Skins diff --git a/docs/CONTENT.md b/docs/CONTENT.md index 63377151..548be95c 100644 --- a/docs/CONTENT.md +++ b/docs/CONTENT.md @@ -80,7 +80,8 @@ Stand dieser Datei: - Startmenue mit `User Setting Bereich`, `Funktion-Bereich` und `Auswahlbereich` - rechter `Infobereich` mit benutzerbezogener Sichtbarkeit - `User Self Management` als Setup-App -- lokale Benutzereinstellungen fuer Desktop-Skin, App-Auswahl, Infobereich und Profildaten +- Benutzereinstellungen fuer Desktop-Skin, App-Auswahl, Infobereich und Profildaten in einer eigenen Datenbanktabelle mit `_user_data`-Suffix +- vorhandene JSON-Dateien dienen nur noch als Fallback oder Uebergang fuer lokale Entwicklung und Altbestaende - LDAP-Synchronisierung fuer Standardfelder wie Name, E-Mail, Telefon, Titel und Ort - `Geburtsdatum` aktuell bewusst nur lokal gespeichert - Keycloak bleibt das Auth-System diff --git a/docs/WEITERENTWICKLUNG.md b/docs/WEITERENTWICKLUNG.md index de342082..890ee941 100644 --- a/docs/WEITERENTWICKLUNG.md +++ b/docs/WEITERENTWICKLUNG.md @@ -30,6 +30,7 @@ Verbindlich ist: - Apps als bereitgestellte Systemfunktionen denken, nicht nur als sichtbare Fenster - Unterschiede zwischen `App`, `Infobereich` und `Widget-Bereich` sauber trennen - lokale Persistenz und spaetere Backend-Synchronisierung getrennt vorbereiten +- Nutzerdaten und Desktop-Preferences bevorzugt datenbankbasiert speichern, nicht dateibasiert - `README.md`-Dateien in Teilbereichen aktuell halten - zentrale Doku bei jeder relevanten Struktur- oder Begriffsanpassung mitpflegen @@ -69,3 +70,4 @@ Bei jeder groesseren Aenderung ist zu pruefen: - die Schriftfarbe von Desktop-Icons passt sich automatisch an den Hintergrund an - Benutzerdaten sollen spaeter sauber in LDAP oder Keycloak geschrieben werden - Standard-Fensterinhalte sollen ein gemeinsames Design nutzen +- Desktop-Nutzereinstellungen liegen in einer eigenen Tabelle mit erkennbarem `_user_data`-Suffix diff --git a/public/assets/desktop/skins/apple/skin.css b/public/assets/desktop/skins/apple/skin.css index b6e0dbd9..cf2e0500 100644 --- a/public/assets/desktop/skins/apple/skin.css +++ b/public/assets/desktop/skins/apple/skin.css @@ -192,8 +192,35 @@ body[data-skin="apple"] .tray-skin-button { body[data-skin="apple"] .widget-card { border-radius: 22px; - background: rgba(255, 255, 255, 0.16); - border-color: rgba(255, 255, 255, 0.22); + color: #0f172a; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(244, 247, 252, 0.68)); + border-color: rgba(255, 255, 255, 0.42); + box-shadow: 0 22px 48px rgba(17, 24, 39, 0.16); + backdrop-filter: blur(34px) saturate(1.3); +} + +body[data-skin="apple"] .widget-card h3, +body[data-skin="apple"] .widget-card p { + color: #0f172a; + text-shadow: none; +} + +body[data-skin="apple"] .widget-card-header p { + color: #475569; +} + +body[data-skin="apple"] .widget-card-action { + background: rgba(51, 65, 85, 0.92); + color: #f8fafc; +} + +body[data-skin="apple"] .widget-card-action:hover { + background: #1e293b; +} + +body[data-skin="apple"] .widget-badge { + background: rgba(51, 65, 85, 0.72); + color: #f8fafc; } body[data-skin="apple"] .desktop-icon { diff --git a/src/App/DesktopUserPreferenceRepository.php b/src/App/DesktopUserPreferenceRepository.php new file mode 100644 index 00000000..85b67fd3 --- /dev/null +++ b/src/App/DesktopUserPreferenceRepository.php @@ -0,0 +1,189 @@ + $config + */ + public function __construct(private readonly array $config) + { + } + + public function available(): bool + { + return $this->pdo() instanceof \PDO; + } + + /** + * @return array|null + */ + public function read(string $userScope): ?array + { + $pdo = $this->pdo(); + if (!$pdo instanceof \PDO || $userScope === '') { + return null; + } + + $this->ensureSchema($pdo); + + $statement = $pdo->prepare( + 'SELECT settings_json + FROM ' . self::TABLE_NAME . ' + WHERE user_scope = :user_scope + LIMIT 1' + ); + $statement->execute(['user_scope' => $userScope]); + $raw = $statement->fetchColumn(); + + if (!is_string($raw) || trim($raw) === '') { + return null; + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : null; + } + + /** + * @param array $settings + * @param array $authUser + */ + public function write(string $userScope, array $settings, array $authUser): void + { + $pdo = $this->pdo(); + if (!$pdo instanceof \PDO || $userScope === '') { + return; + } + + $this->ensureSchema($pdo); + + $payload = [ + 'user_scope' => $userScope, + 'username' => $this->limit((string) ($authUser['username'] ?? ''), 191), + 'subject_id' => $this->limit((string) ($authUser['sub'] ?? ''), 191), + 'settings_json' => json_encode($settings, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), + ]; + + $driver = strtolower((string) $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME)); + if ($driver === 'mysql') { + $statement = $pdo->prepare( + 'INSERT INTO ' . self::TABLE_NAME . ' (user_scope, username, subject_id, settings_json) + VALUES (:user_scope, :username, :subject_id, :settings_json) + ON DUPLICATE KEY UPDATE + username = VALUES(username), + subject_id = VALUES(subject_id), + settings_json = VALUES(settings_json), + updated_at = CURRENT_TIMESTAMP' + ); + $statement->execute($payload); + return; + } + + $updated = $pdo->prepare( + 'UPDATE ' . self::TABLE_NAME . ' + SET username = :username, + subject_id = :subject_id, + settings_json = :settings_json, + updated_at = CURRENT_TIMESTAMP + WHERE user_scope = :user_scope' + ); + $updated->execute($payload); + + if ($updated->rowCount() > 0) { + return; + } + + $insert = $pdo->prepare( + 'INSERT INTO ' . self::TABLE_NAME . ' (user_scope, username, subject_id, settings_json, created_at, updated_at) + VALUES (:user_scope, :username, :subject_id, :settings_json, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)' + ); + $insert->execute($payload); + } + + private function pdo(): ?\PDO + { + if ($this->pdo instanceof \PDO) { + return $this->pdo; + } + + if (empty($this->config['enabled']) || !is_array($this->config['db'] ?? null) || $this->config['db'] === []) { + return null; + } + + try { + $this->pdo = PdoFactory::createFromArray($this->config['db']); + } catch (\Throwable $exception) { + error_log('[desktop-user-preferences] DB unavailable: ' . $exception->getMessage()); + $this->pdo = null; + } + + return $this->pdo; + } + + private function ensureSchema(\PDO $pdo): void + { + if ($this->schemaEnsured) { + return; + } + + $driver = strtolower((string) $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME)); + $sql = match ($driver) { + 'pgsql' => << << <<exec($sql); + $this->schemaEnsured = true; + } + + private function limit(string $value, int $maxLength): string + { + $trimmed = trim($value); + + if (function_exists('mb_substr')) { + return mb_substr($trimmed, 0, $maxLength); + } + + return substr($trimmed, 0, $maxLength); + } +} diff --git a/src/App/PdoFactory.php b/src/App/PdoFactory.php new file mode 100644 index 00000000..100dd102 --- /dev/null +++ b/src/App/PdoFactory.php @@ -0,0 +1,107 @@ + $db + */ + public static function createFromArray(array $db): \PDO + { + $driver = (string) ($db['driver'] ?? ''); + if ($driver === '') { + throw new \RuntimeException('DB config missing "driver".'); + } + + $dsn = match ($driver) { + 'mysql' => self::buildMysqlDsn($db), + 'pgsql' => self::buildPgsqlDsn($db), + 'sqlite' => self::buildSqliteDsn($db), + default => throw new \RuntimeException('Unsupported PDO driver: ' . $driver), + }; + + $pdo = new \PDO( + $dsn, + (string) ($db['user'] ?? ''), + (string) ($db['password'] ?? ''), + (array) ($db['options'] ?? []) + ); + + if ($driver === 'pgsql' && !empty($db['schema'])) { + $schema = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $db['schema']); + if ($schema !== null && $schema !== '') { + $pdo->exec('SET search_path TO ' . $schema); + } + } + + return $pdo; + } + + /** + * @param array $db + */ + private static function buildMysqlDsn(array $db): string + { + if (empty($db['dbname'])) { + throw new \RuntimeException('MySQL config missing "dbname".'); + } + + $charset = (string) ($db['charset'] ?? 'utf8mb4'); + + if (!empty($db['unix_socket'])) { + return sprintf( + 'mysql:unix_socket=%s;dbname=%s;charset=%s', + (string) $db['unix_socket'], + (string) $db['dbname'], + $charset + ); + } + + return sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + (string) ($db['host'] ?? 'localhost'), + (int) ($db['port'] ?? 3306), + (string) $db['dbname'], + $charset + ); + } + + /** + * @param array $db + */ + private static function buildPgsqlDsn(array $db): string + { + if (empty($db['dbname'])) { + throw new \RuntimeException('PostgreSQL config missing "dbname".'); + } + + $dsn = sprintf( + 'pgsql:host=%s;port=%d;dbname=%s', + (string) ($db['host'] ?? 'localhost'), + (int) ($db['port'] ?? 5432), + (string) $db['dbname'] + ); + + if (isset($db['connect_timeout'])) { + $dsn .= ';connect_timeout=' . max(1, (int) $db['connect_timeout']); + } + + return $dsn; + } + + /** + * @param array $db + */ + private static function buildSqliteDsn(array $db): string + { + $path = (string) ($db['path'] ?? ''); + if ($path === '') { + throw new \RuntimeException('SQLite config missing "path".'); + } + + return 'sqlite:' . $path; + } +} diff --git a/src/App/UserSelfManagementService.php b/src/App/UserSelfManagementService.php index dba210a8..329338b9 100644 --- a/src/App/UserSelfManagementService.php +++ b/src/App/UserSelfManagementService.php @@ -37,7 +37,7 @@ final class UserSelfManagementService public function load(array $authUser, array $visibleApps, array $widgets, array $availableSkins): array { return $this->normalizeSettings( - $this->store($this->storageScope($authUser))->read(), + $this->readRawSettings($authUser), $authUser, $visibleApps, $widgets, @@ -60,7 +60,7 @@ final class UserSelfManagementService $normalized = $this->normalizeSettings($merged, $authUser, $visibleApps, $widgets, $availableSkins); $normalized['updated_at'] = gmdate(DATE_ATOM); - $this->store($this->storageScope($authUser))->write($normalized); + $this->writeRawSettings($normalized, $authUser); return $normalized; } @@ -142,7 +142,7 @@ final class UserSelfManagementService 'meta' => [ 'storage_scope' => $this->storageScope($authUser), 'authenticated' => ($authUser['sub'] ?? '') !== '' || ($authUser['username'] ?? '') !== '', - 'sync_mode' => 'local-profile', + 'sync_mode' => $this->repository()->available() ? 'base-db-user-data' : 'local-fallback', 'sync_targets' => [ 'ldap' => 'planned', 'keycloak' => 'planned', @@ -162,11 +162,26 @@ final class UserSelfManagementService return $sanitized !== null && $sanitized !== '' ? $sanitized : 'guest'; } - private function store(string $scope): JsonStore + 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 @@ -236,6 +251,49 @@ final class UserSelfManagementService ]; } + /** + * @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