Merge pull request #479 from Vateron-Media/fix/reseller-ui-prefs-persist

fix(reseller): persist the customizer theme and seed it from the profile
This commit is contained in:
Danil
2026-10-27 18:09:10 +03:00
committed by GitHub
8 changed files with 180 additions and 86 deletions
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace XcVm\Domain\User;
use XcVm\Core\Enum\Theme;
use XcVm\Infrastructure\Database\DatabaseAware;
/**
* Per-user Bootstrap 5 customizer state (users.ui_prefs), shared by the admin
* and reseller panels. Only whitelisted keys with valid values are stored.
*
* @package XC_VM_Domain_User
* @author Divarion_D <https://github.com/Divarion-D>
* @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<string, mixed> $rUserInfo
* @return array{0: array<string, mixed>, 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<string, mixed> $rIn
* @return array<string, mixed>
*/
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<string, mixed> $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);
}
}
@@ -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();
}
}
@@ -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.
*/
+4 -24
View File
@@ -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);
?>
<!doctype html>
<html
@@ -115,16 +104,7 @@ $xmUiPrefs['rtl'] = $xmIsRtl;
<link rel="stylesheet" href="assets/xcvm/rtl.css">
<?php endif; ?>
<!-- Helpers + template customizer must precede config.js -->
<script src="assets/vendor/js/helpers.js"></script>
<script src="assets/vendor/js/template-customizer.js"></script>
<!-- Per-user customizer state (server-authoritative) consumed by config.js -->
<script>
window.XC_VM = window.XC_VM || {};
window.XC_VM.uiPrefsUrl = './api?action=save_ui_prefs';
window.XC_VM_UIPrefs = <?= json_encode($xmUiPrefs, JSON_UNESCAPED_SLASHES); ?>;
</script>
<script src="assets/js/config.js"></script>
<?php require __DIR__ . '/../layouts/ui_prefs_head.php'; ?>
</head>
<?php if (!empty($GLOBALS['_SETUP'])): /* setup wizard — branded bare shell: no sidebar / navbar / menu, and never touches $rUserInfo */ ?>
+4 -24
View File
@@ -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);
?>
<!doctype html>
<html
@@ -116,16 +105,7 @@ $xmUiPrefs['rtl'] = $xmIsRtl;
<link rel="stylesheet" href="assets/xcvm/rtl.css">
<?php endif; ?>
<!-- Helpers + template customizer must precede config.js -->
<script src="assets/vendor/js/helpers.js"></script>
<script src="assets/vendor/js/template-customizer.js"></script>
<!-- Per-user customizer state (theme persists in localStorage via the
customizer; the reseller api exposes no save_ui_prefs endpoint yet). -->
<script>
window.XC_VM = window.XC_VM || {};
window.XC_VM_UIPrefs = <?= json_encode($xmUiPrefs, JSON_UNESCAPED_SLASHES); ?>;
</script>
<script src="assets/js/config.js"></script>
<?php require __DIR__ . '/../ui_prefs_head.php'; ?>
</head>
<?php if (isset($_GET['modal'])): /* iframe modal shell — no sidebar / navbar / topbar */ ?>
@@ -0,0 +1,10 @@
<!-- Helpers + template customizer must precede config.js -->
<script src="assets/vendor/js/helpers.js"></script>
<script src="assets/vendor/js/template-customizer.js"></script>
<!-- Per-user customizer state (server-authoritative) consumed by config.js -->
<script>
window.XC_VM = window.XC_VM || {};
window.XC_VM.uiPrefsUrl = './api?action=save_ui_prefs';
window.XC_VM_UIPrefs = <?= json_encode($xmUiPrefs, JSON_UNESCAPED_SLASHES); ?>;
</script>
<script src="assets/js/config.js"></script>
+1
View File
@@ -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']);
+41
View File
@@ -0,0 +1,41 @@
<?php
use PHPUnit\Framework\TestCase;
use XcVm\Domain\User\UiPrefs;
/**
* The whitelist shared by the admin and reseller customizer endpoints: unknown
* keys and invalid values must never reach users.ui_prefs.
*/
final class UiPrefsTest extends TestCase {
public function testKeepsValidWhitelistedValues(): void {
$rIn = ['theme' => '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' => '<script>', 'rtl' => 'maybe'];
$this->assertSame([], UiPrefs::sanitize($rIn));
}
}