mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-31 11:03:22 +01:00
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:
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 */ ?>
|
||||
|
||||
@@ -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>
|
||||
@@ -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']);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user