From fcba3d31cfaed691e98f627bf301e33141c523fe Mon Sep 17 00:00:00 2001 From: Divarion_D Date: Mon, 26 Oct 2026 21:35:16 +0300 Subject: [PATCH] fix(reseller): persist the customizer theme and seed it from the profile Resellers could not save their customizer state: the reseller header never set uiPrefsUrl and no save_ui_prefs endpoint existed on the reseller API. config.js then primed "light" over the saved choice on every page load. - Add save_ui_prefs to the reseller API (own row only, XHR-guarded). - Move the whitelist and the save/reset logic into Domain\User\UiPrefs, shared by the admin and reseller endpoints. - Compute the first-paint prefs in UiPrefs::pageState(): an unset theme is seeded from the profile's System Theme. - Share the customizer script block between the admin and reseller headers via layouts/ui_prefs_head.php. Admin behaviour change: with no saved theme, admin now follows the profile theme instead of always starting in light. --- src/Domain/User/UiPrefs.php | 92 +++++++++++++++++++ .../Admin/Ajax/StatsAjaxController.php | 43 +-------- .../Reseller/Ajax/StatsAjaxController.php | 23 +++++ src/Public/Views/admin/header.php | 28 +----- src/Public/Views/layouts/reseller/header.php | 28 +----- src/Public/Views/layouts/ui_prefs_head.php | 10 ++ src/Public/routes/reseller.php | 1 + tests/Unit/UiPrefsTest.php | 41 +++++++++ 8 files changed, 180 insertions(+), 86 deletions(-) create mode 100644 src/Domain/User/UiPrefs.php create mode 100644 src/Public/Views/layouts/ui_prefs_head.php create mode 100644 tests/Unit/UiPrefsTest.php diff --git a/src/Domain/User/UiPrefs.php b/src/Domain/User/UiPrefs.php new file mode 100644 index 00000000..979ee4c9 --- /dev/null +++ b/src/Domain/User/UiPrefs.php @@ -0,0 +1,92 @@ + + * @copyright 2025-2026 Vateron Media + * @link https://github.com/Vateron-Media/XC_VM + * @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html + */ +final class UiPrefs { + use DatabaseAware; + + /** + * Prefs for the shell's first paint and the customizer: the stored prefs, with + * the theme seeded from the profile's System Theme when none is saved (config.js + * would otherwise prime 'light'), and the RTL flag bound to the language. + * + * @param array $rUserInfo + * @return array{0: array, 1: string} [prefs, data-bs-theme value] + */ + public static function pageState(array $rUserInfo, bool $rIsRtl): array { + $rIsDark = Theme::fromId($rUserInfo['theme'] ?? 0)->isDark(); + $rPrefs = json_decode($rUserInfo['ui_prefs'] ?? '', true) ?: []; + $rPrefs['theme'] ??= $rIsDark ? 'dark' : 'light'; + $rPrefs['rtl'] = $rIsRtl; + + // 'system' (or any other value) follows the profile's theme column. + $rTheme = $rPrefs['theme']; + $rBsTheme = $rTheme === 'dark' ? 'dark' : ($rTheme === 'light' ? 'light' : ($rIsDark ? 'dark' : 'light')); + + return [$rPrefs, $rBsTheme]; + } + + /** + * Keep only the whitelisted keys whose values pass validation. + * + * @param array $rIn + * @return array + */ + public static function sanitize(array $rIn): array { + $rEnum = static fn($rVal, array $rAllowed) => in_array($rVal, $rAllowed, true) ? $rVal : null; + $rBool = static fn($rVal) => ($rVal === true || $rVal === 'true') ? true : (($rVal === false || $rVal === 'false') ? false : null); + $rMap = [ + 'theme' => static fn($v) => $rEnum($v, ['light', 'dark', 'system']), + 'skin' => static fn($v) => $rEnum($v, ['default', 'bordered']), + 'semiDark' => $rBool, + 'layoutCollapsed' => $rBool, + 'rtl' => $rBool, + 'navbar' => static fn($v) => $rEnum($v, ['sticky', 'static', 'hidden']), + 'headerType' => static fn($v) => $rEnum($v, ['static', 'fixed']), + 'contentLayout' => static fn($v) => $rEnum($v, ['compact', 'wide']), + 'color' => static fn($v) => (is_string($v) && preg_match('/^#[0-9a-fA-F]{6}$/', $v)) ? $v : null, + 'lang' => static fn($v) => (is_string($v) && preg_match('/^[a-z]{2,8}$/', $v)) ? $v : null, + ]; + + $rClean = []; + foreach ($rMap as $rKey => $rValidator) { + if (array_key_exists($rKey, $rIn)) { + $rVal = $rValidator($rIn[$rKey]); + if ($rVal !== null) { + $rClean[$rKey] = $rVal; + } + } + } + + return $rClean; + } + + /** + * Store the sanitized prefs for one user. The customizer's "Reset" sends + * `__reset` and clears them, so the panel falls back to the project defaults. + * + * @param array $rIn + */ + public static function save(int $rUserId, array $rIn): void { + if (!empty($rIn['__reset'])) { + self::db()->query('UPDATE `users` SET `ui_prefs` = NULL WHERE `id` = ?;', $rUserId); + + return; + } + + self::db()->query('UPDATE `users` SET `ui_prefs` = ? WHERE `id` = ?;', json_encode(self::sanitize($rIn)), $rUserId); + } +} diff --git a/src/Public/Controllers/Admin/Ajax/StatsAjaxController.php b/src/Public/Controllers/Admin/Ajax/StatsAjaxController.php index 8222e51d..56222cd4 100644 --- a/src/Public/Controllers/Admin/Ajax/StatsAjaxController.php +++ b/src/Public/Controllers/Admin/Ajax/StatsAjaxController.php @@ -8,6 +8,7 @@ use XcVm\Core\Http\RequestManager; use XcVm\Core\Util\AdminHelpers; use XcVm\Domain\Server\ServerRepository; use XcVm\Domain\Stream\StreamRepository; +use XcVm\Domain\User\UiPrefs; /** * Admin-ajax controller for the "Stats & Graphs" group. @@ -425,7 +426,7 @@ class StatsAjaxController extends BaseAjaxController { $this->fail(); } - global $db, $rUserInfo; + global $rUserInfo; if (empty($rUserInfo['id'])) { $this->fail(); } @@ -435,43 +436,9 @@ class StatsAjaxController extends BaseAjaxController { $this->fail(); } - // Customizer "Reset" — drop the saved prefs so the panel falls back to the - // project defaults (XC_VM_UIDefaults) on the next load. - if (!empty($rIn['__reset'])) { - $db->query('UPDATE `users` SET `ui_prefs` = NULL WHERE `id` = ?;', intval($rUserInfo['id'])); - $this->ok(); - } - - // Whitelist + validate — never store arbitrary keys/values. - $rClean = []; - $rEnum = static function ($rVal, array $rAllowed) { - return in_array($rVal, $rAllowed, true) ? $rVal : null; - }; - $rBool = static function ($rVal) { - return ($rVal === true || $rVal === 'true') ? true : (($rVal === false || $rVal === 'false') ? false : null); - }; - $rMap = [ - 'theme' => static fn($v) => $rEnum($v, ['light', 'dark', 'system']), - 'skin' => static fn($v) => in_array($v, ['default', 'bordered'], true) ? $v : null, - 'semiDark' => $rBool, - 'layoutCollapsed' => $rBool, - 'rtl' => $rBool, - 'navbar' => static fn($v) => $rEnum($v, ['sticky', 'static', 'hidden']), - 'headerType' => static fn($v) => $rEnum($v, ['static', 'fixed']), - 'contentLayout' => static fn($v) => $rEnum($v, ['compact', 'wide']), - 'color' => static fn($v) => (is_string($v) && preg_match('/^#[0-9a-fA-F]{6}$/', $v)) ? $v : null, - 'lang' => static fn($v) => (is_string($v) && preg_match('/^[a-z]{2,8}$/', $v)) ? $v : null, - ]; - foreach ($rMap as $rKey => $rValidator) { - if (array_key_exists($rKey, $rIn)) { - $rVal = $rValidator($rIn[$rKey]); - if ($rVal !== null) { - $rClean[$rKey] = $rVal; - } - } - } - - $db->query('UPDATE `users` SET `ui_prefs` = ? WHERE `id` = ?;', json_encode($rClean), intval($rUserInfo['id'])); + // Also handles the customizer "Reset" (`__reset`): the saved prefs are dropped + // and the panel falls back to XC_VM_UIDefaults. + UiPrefs::save(intval($rUserInfo['id']), $rIn); $this->ok(); } } diff --git a/src/Public/Controllers/Reseller/Ajax/StatsAjaxController.php b/src/Public/Controllers/Reseller/Ajax/StatsAjaxController.php index 03638aaa..a10852aa 100644 --- a/src/Public/Controllers/Reseller/Ajax/StatsAjaxController.php +++ b/src/Public/Controllers/Reseller/Ajax/StatsAjaxController.php @@ -5,6 +5,7 @@ namespace XcVm\Public\Controllers\Reseller\Ajax; use XcVm\Core\Config\SettingsManager; use XcVm\Domain\Stream\ConnectionTracker; use XcVm\Domain\User\ResellerAPI; +use XcVm\Domain\User\UiPrefs; use XcVm\Domain\User\UserCredits; use XcVm\Infrastructure\Database\DatabaseAware; @@ -60,6 +61,28 @@ class StatsAjaxController extends BaseResellerAjaxController { exit(); } + /** + * action=save_ui_prefs — persist the signed-in reseller's customizer state. + * Writes only the reseller's own row, never a client-supplied id. + */ + public function saveUiPrefs(): void { + [$rUserInfo] = $this->begin(); + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + echo json_encode(['result' => false]); + exit(); + } + + $rIn = json_decode((string) file_get_contents('php://input'), true); + if (!is_array($rIn)) { + echo json_encode(['result' => false]); + exit(); + } + + UiPrefs::save(intval($rUserInfo['id']), $rIn); + echo json_encode(['result' => true]); + exit(); + } + /** * Output header summary statistics (JSON) and exit. */ diff --git a/src/Public/Views/admin/header.php b/src/Public/Views/admin/header.php index 2a44b98a..15906cb9 100644 --- a/src/Public/Views/admin/header.php +++ b/src/Public/Views/admin/header.php @@ -15,24 +15,15 @@ use XcVm\Core\Auth\Authorization; use XcVm\Core\Config\SettingsManager; -use XcVm\Core\Enum\Theme; use XcVm\Core\Module\NavbarItem; use XcVm\Core\Module\NavbarRegistry; +use XcVm\Domain\User\UiPrefs; if (count(get_included_files()) == 1) { exit(); } $rUpdate = (json_decode((string) SettingsManager::getAll()['update_data'], true) ?: []); -$xmIsDark = Theme::fromId($rUserInfo['theme'] ?? 0)->isDark(); - -// Per-user Bootstrap 5 customizer state (see config.js + StatsAjaxController::saveUiPrefs). -// The stored theme wins for the initial data-bs-theme paint; 'system'/unset falls -// back to the legacy per-user theme column so there is no flash. -$xmUiPrefs = json_decode($rUserInfo['ui_prefs'] ?? '', true) ?: []; -$xmThemePref = $xmUiPrefs['theme'] ?? null; -$xmBsTheme = $xmThemePref === 'dark' ? 'dark' : ($xmThemePref === 'light' ? 'light' : ($xmIsDark ? 'dark' : 'light')); - /** * Shared navbar helpers (identical contract to legacy header.php). Guarded so a * single request only ever defines them once regardless of which header ran. @@ -67,10 +58,8 @@ if (!function_exists('_xc_nav_label')) { } $xmCurrentLang = \XcVm\Core\Localization\Translator::current(); $xmIsRtl = \XcVm\Core\Localization\Translator::isRtl($xmCurrentLang); -// The Vuexy customizer (config.js) derives the text direction from the per-user -// `rtl` pref, not the language. Bind it to the language so an RTL language -// mirrors the layout and switching back to an LTR language clears it. -$xmUiPrefs['rtl'] = $xmIsRtl; +// Customizer prefs; see UiPrefs::pageState() and layouts/ui_prefs_head.php. +[$xmUiPrefs, $xmBsTheme] = UiPrefs::pageState($rUserInfo, $xmIsRtl); ?> - - - - - - + diff --git a/src/Public/Views/layouts/reseller/header.php b/src/Public/Views/layouts/reseller/header.php index 9f53569f..52fa2077 100644 --- a/src/Public/Views/layouts/reseller/header.php +++ b/src/Public/Views/layouts/reseller/header.php @@ -14,24 +14,15 @@ */ use XcVm\Core\Auth\Authorization; -use XcVm\Core\Enum\Theme; use XcVm\Core\Module\NavbarItem; use XcVm\Core\Module\ResellerNavbarRegistry; use XcVm\Core\Util\AdminHelpers; +use XcVm\Domain\User\UiPrefs; if (count(get_included_files()) == 1) { exit(); } -$xmIsDark = Theme::fromId($rUserInfo['theme'] ?? 0)->isDark(); - -// Per-user Bootstrap 5 customizer state (shared config.js contract). The stored -// theme wins for the initial data-bs-theme paint; 'system'/unset falls back to -// the legacy per-user theme column so there is no flash. -$xmUiPrefs = json_decode($rUserInfo['ui_prefs'] ?? '', true) ?: []; -$xmThemePref = $xmUiPrefs['theme'] ?? null; -$xmBsTheme = $xmThemePref === 'dark' ? 'dark' : ($xmThemePref === 'light' ? 'light' : ($xmIsDark ? 'dark' : 'light')); - /** * Shared reseller navbar helpers (same contract as admin/header.php's * _xc_nav_visible()/_xc_nav_label(), against ResellerNavbarRegistry and @@ -68,10 +59,8 @@ if (!function_exists('_xc_reseller_nav_label')) { } $xmCurrentLang = \XcVm\Core\Localization\Translator::current(); $xmIsRtl = \XcVm\Core\Localization\Translator::isRtl($xmCurrentLang); -// The Vuexy customizer (config.js) derives the text direction from the per-user -// `rtl` pref, not the language. Bind it to the language so an RTL language -// mirrors the layout and switching back to an LTR language clears it. -$xmUiPrefs['rtl'] = $xmIsRtl; +// Customizer prefs; see UiPrefs::pageState() and ui_prefs_head.php. +[$xmUiPrefs, $xmBsTheme] = UiPrefs::pageState($rUserInfo, $xmIsRtl); ?> - - - - - - + diff --git a/src/Public/Views/layouts/ui_prefs_head.php b/src/Public/Views/layouts/ui_prefs_head.php new file mode 100644 index 00000000..f342f03e --- /dev/null +++ b/src/Public/Views/layouts/ui_prefs_head.php @@ -0,0 +1,10 @@ + + + + + + diff --git a/src/Public/routes/reseller.php b/src/Public/routes/reseller.php index a2c2173a..c6a61605 100644 --- a/src/Public/routes/reseller.php +++ b/src/Public/routes/reseller.php @@ -81,6 +81,7 @@ $router->post('api', [ResellerApiController::class, 'index']); $router->api('dashboard', [StatsAjaxController::class, 'dashboard']); $router->api('header_stats', [StatsAjaxController::class, 'headerStats']); $router->api('stats', [StatsAjaxController::class, 'stats']); +$router->api('save_ui_prefs', [StatsAjaxController::class, 'saveUiPrefs']); $router->api('line', [LineAjaxController::class, 'line']); $router->api('line_activity', [LineAjaxController::class, 'lineActivity']); diff --git a/tests/Unit/UiPrefsTest.php b/tests/Unit/UiPrefsTest.php new file mode 100644 index 00000000..7d275a97 --- /dev/null +++ b/tests/Unit/UiPrefsTest.php @@ -0,0 +1,41 @@ + 'dark', 'color' => '#7367f0', 'semiDark' => 'true', 'lang' => 'en']; + + $this->assertSame( + ['theme' => 'dark', 'semiDark' => true, 'color' => '#7367f0', 'lang' => 'en'], + UiPrefs::sanitize($rIn) + ); + } + + public function testUnsetThemeIsSeededFromTheProfile(): void { + [$rPrefs, $rBsTheme] = UiPrefs::pageState(['theme' => 1, 'ui_prefs' => null], false); + + $this->assertSame('dark', $rPrefs['theme']); + $this->assertSame('dark', $rBsTheme); + $this->assertFalse($rPrefs['rtl']); + } + + public function testSavedThemeWinsOverTheProfile(): void { + [$rPrefs, $rBsTheme] = UiPrefs::pageState(['theme' => 1, 'ui_prefs' => '{"theme":"light"}'], true); + + $this->assertSame('light', $rPrefs['theme']); + $this->assertSame('light', $rBsTheme); + $this->assertTrue($rPrefs['rtl']); + } + + public function testDropsUnknownKeysAndInvalidValues(): void { + $rIn = ['theme' => 'neon', 'color' => 'red', 'skin' => 1, 'evil' => '