Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
88.70% |
157 / 177 |
|
55.00% |
11 / 20 |
CRAP | |
0.00% |
0 / 1 |
| UserOptionsManager | |
89.20% |
157 / 176 |
|
55.00% |
11 / 20 |
85.65 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| getDefaultOptions | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getDefaultOption | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getOption | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| getOptions | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
7.03 | |||
| isOptionGlobal | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| getOptionBatchForUserNames | |
85.00% |
17 / 20 |
|
0.00% |
0 / 1 |
9.27 | |||
| setOption | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| resetOptionsByName | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| resetAllOptions | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| saveOptions | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| saveOptionsInternal | |
100.00% |
33 / 33 |
|
100.00% |
1 / 1 |
15 | |||
| loadUserOptions | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
2.02 | |||
| clearUserOptionsCache | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| loadOptionsFromStore | |
88.24% |
15 / 17 |
|
0.00% |
0 / 1 |
8.10 | |||
| normalizeValueType | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| loadOriginalOptions | |
83.33% |
25 / 30 |
|
0.00% |
0 / 1 |
8.30 | |||
| isValueEqual | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
| getStores | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
| getStoreNameForGlobalCreate | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-2.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\User\Options; |
| 8 | |
| 9 | use InvalidArgumentException; |
| 10 | use MediaWiki\Config\ServiceOptions; |
| 11 | use MediaWiki\HookContainer\HookContainer; |
| 12 | use MediaWiki\HookContainer\HookRunner; |
| 13 | use MediaWiki\Language\LanguageCode; |
| 14 | use MediaWiki\Language\LanguageConverter; |
| 15 | use MediaWiki\Language\LanguageConverterFactory; |
| 16 | use MediaWiki\MainConfigNames; |
| 17 | use MediaWiki\User\UserFactory; |
| 18 | use MediaWiki\User\UserIdentity; |
| 19 | use MediaWiki\User\UserNameUtils; |
| 20 | use MediaWiki\User\UserTimeCorrection; |
| 21 | use Psr\Log\LoggerInterface; |
| 22 | use Wikimedia\ObjectFactory\ObjectFactory; |
| 23 | use Wikimedia\Rdbms\IConnectionProvider; |
| 24 | use Wikimedia\Rdbms\IDBAccessObject; |
| 25 | |
| 26 | /** |
| 27 | * A service class to control user options |
| 28 | * @since 1.35 |
| 29 | * @ingroup User |
| 30 | */ |
| 31 | class UserOptionsManager extends UserOptionsLookup { |
| 32 | |
| 33 | /** |
| 34 | * @internal For use by ServiceWiring |
| 35 | */ |
| 36 | public const CONSTRUCTOR_OPTIONS = [ |
| 37 | MainConfigNames::HiddenPrefs, |
| 38 | MainConfigNames::LocalTZoffset, |
| 39 | ]; |
| 40 | |
| 41 | /** |
| 42 | * @since 1.39.5, 1.40 |
| 43 | */ |
| 44 | public const MAX_BYTES_OPTION_VALUE = 65530; |
| 45 | |
| 46 | /** |
| 47 | * If the option was set globally, ignore the update. |
| 48 | * @since 1.43 |
| 49 | */ |
| 50 | public const GLOBAL_IGNORE = 'ignore'; |
| 51 | |
| 52 | /** |
| 53 | * If the option was set globally, add a local override. |
| 54 | * @since 1.43 |
| 55 | */ |
| 56 | public const GLOBAL_OVERRIDE = 'override'; |
| 57 | |
| 58 | /** |
| 59 | * If the option was set globally, update the global value. |
| 60 | * @since 1.43 |
| 61 | */ |
| 62 | public const GLOBAL_UPDATE = 'update'; |
| 63 | |
| 64 | /** |
| 65 | * Create a new global preference in the first available global store. |
| 66 | * If there are no global stores, update the local value. If there was |
| 67 | * already a global preference, update it. |
| 68 | * @since 1.44 |
| 69 | */ |
| 70 | public const GLOBAL_CREATE = 'create'; |
| 71 | |
| 72 | private const LOCAL_STORE_KEY = 'local'; |
| 73 | |
| 74 | private readonly HookRunner $hookRunner; |
| 75 | |
| 76 | /** @var UserOptionsCacheEntry[] */ |
| 77 | private $cache = []; |
| 78 | |
| 79 | /** @var UserOptionsStore[]|null */ |
| 80 | private $stores; |
| 81 | |
| 82 | public function __construct( |
| 83 | private readonly ServiceOptions $serviceOptions, |
| 84 | private readonly DefaultOptionsLookup $defaultOptionsLookup, |
| 85 | private readonly LanguageConverterFactory $languageConverterFactory, |
| 86 | private readonly IConnectionProvider $dbProvider, |
| 87 | private readonly LoggerInterface $logger, |
| 88 | HookContainer $hookContainer, |
| 89 | private readonly UserFactory $userFactory, |
| 90 | private readonly UserNameUtils $userNameUtils, |
| 91 | private readonly ObjectFactory $objectFactory, |
| 92 | private readonly array $storeProviders, |
| 93 | ) { |
| 94 | parent::__construct( $userNameUtils ); |
| 95 | $serviceOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
| 96 | $this->hookRunner = new HookRunner( $hookContainer ); |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * @inheritDoc |
| 101 | */ |
| 102 | public function getDefaultOptions( ?UserIdentity $userIdentity = null ): array { |
| 103 | return $this->defaultOptionsLookup->getDefaultOptions( $userIdentity ); |
| 104 | } |
| 105 | |
| 106 | /** |
| 107 | * @inheritDoc |
| 108 | */ |
| 109 | public function getDefaultOption( string $opt, ?UserIdentity $userIdentity = null ) { |
| 110 | return $this->defaultOptionsLookup->getDefaultOption( $opt, $userIdentity ); |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * @inheritDoc |
| 115 | */ |
| 116 | public function getOption( |
| 117 | UserIdentity $user, |
| 118 | string $oname, |
| 119 | $defaultOverride = null, |
| 120 | bool $ignoreHidden = false, |
| 121 | int $queryFlags = IDBAccessObject::READ_NORMAL |
| 122 | ) { |
| 123 | # We want 'disabled' preferences to always behave as the default value for |
| 124 | # users, even if they have set the option explicitly in their settings (ie they |
| 125 | # set it, and then it was disabled removing their ability to change it). But |
| 126 | # we don't want to erase the preferences in the database in case the preference |
| 127 | # is re-enabled again. So don't touch $mOptions, just override the returned value |
| 128 | if ( !$ignoreHidden && in_array( $oname, $this->serviceOptions->get( MainConfigNames::HiddenPrefs ) ) ) { |
| 129 | return $this->defaultOptionsLookup->getDefaultOption( $oname, $user ); |
| 130 | } |
| 131 | |
| 132 | $options = $this->loadUserOptions( $user, $queryFlags ); |
| 133 | if ( array_key_exists( $oname, $options ) ) { |
| 134 | return $options[$oname]; |
| 135 | } |
| 136 | return $defaultOverride; |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * @inheritDoc |
| 141 | */ |
| 142 | public function getOptions( |
| 143 | UserIdentity $user, |
| 144 | int $flags = 0, |
| 145 | int $queryFlags = IDBAccessObject::READ_NORMAL |
| 146 | ): array { |
| 147 | $options = $this->loadUserOptions( $user, $queryFlags ); |
| 148 | |
| 149 | # We want 'disabled' preferences to always behave as the default value for |
| 150 | # users, even if they have set the option explicitly in their settings (ie they |
| 151 | # set it, and then it was disabled removing their ability to change it). But |
| 152 | # we don't want to erase the preferences in the database in case the preference |
| 153 | # is re-enabled again. So don't touch $mOptions, just override the returned value |
| 154 | foreach ( $this->serviceOptions->get( MainConfigNames::HiddenPrefs ) as $pref ) { |
| 155 | $default = $this->defaultOptionsLookup->getDefaultOption( $pref, $user ); |
| 156 | if ( $default !== null ) { |
| 157 | $options[$pref] = $default; |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | if ( $flags & self::EXCLUDE_DEFAULTS ) { |
| 162 | // NOTE: This intentionally ignores conditional defaults, so that `mw.user.options` |
| 163 | // work correctly for options with conditional defaults. |
| 164 | $defaultOptions = $this->defaultOptionsLookup->getDefaultOptions( null ); |
| 165 | foreach ( $options as $option => $value ) { |
| 166 | if ( array_key_exists( $option, $defaultOptions ) |
| 167 | && self::isValueEqual( $value, $defaultOptions[$option] ) |
| 168 | ) { |
| 169 | unset( $options[$option] ); |
| 170 | } |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | return $options; |
| 175 | } |
| 176 | |
| 177 | /** @inheritDoc */ |
| 178 | public function isOptionGlobal( UserIdentity $user, string $key ) { |
| 179 | $this->getOptions( $user ); |
| 180 | $source = $this->cache[ $this->getCacheKey( $user ) ]->sources[$key] ?? self::LOCAL_STORE_KEY; |
| 181 | return $source !== self::LOCAL_STORE_KEY; |
| 182 | } |
| 183 | |
| 184 | /** @inheritDoc */ |
| 185 | public function getOptionBatchForUserNames( array $users, string $key ) { |
| 186 | if ( !$users ) { |
| 187 | return []; |
| 188 | } |
| 189 | |
| 190 | $exceptionKey = $key . self::LOCAL_EXCEPTION_SUFFIX; |
| 191 | $results = []; |
| 192 | $stores = $this->getStores(); |
| 193 | foreach ( $stores as $storeName => $store ) { |
| 194 | // Check the exception key in the local store, if there is more than one store |
| 195 | if ( count( $stores ) > 1 && $storeName === self::LOCAL_STORE_KEY ) { |
| 196 | $storeResults = $store->fetchBatchForUserNames( [ $key, $exceptionKey ], $users ); |
| 197 | $values = $storeResults[$key] ?? []; |
| 198 | $exceptions = $storeResults[$exceptionKey] ?? []; |
| 199 | foreach ( $values as $userName => $value ) { |
| 200 | if ( !empty( $exceptions[$userName] ) || !isset( $results[$userName] ) ) { |
| 201 | $results[$userName] = $value; |
| 202 | } |
| 203 | } |
| 204 | } else { |
| 205 | $storeResults = $store->fetchBatchForUserNames( [ $key ], $users ); |
| 206 | $results += $storeResults[$key] ?? []; |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | // If $key has a conditional default, DefaultOptionsLookup will be expensive, |
| 211 | // so it makes sense to only ask for the users without an option set. |
| 212 | $usersNeedingDefaults = array_diff( $users, array_keys( $results ) ); |
| 213 | if ( $usersNeedingDefaults ) { |
| 214 | $defaults = $this->defaultOptionsLookup->getOptionBatchForUserNames( $usersNeedingDefaults, $key ); |
| 215 | $results += $defaults; |
| 216 | } |
| 217 | |
| 218 | return $results; |
| 219 | } |
| 220 | |
| 221 | /** |
| 222 | * Set the given option for a user. |
| 223 | * |
| 224 | * You need to call saveOptions() to actually write to the database. |
| 225 | * |
| 226 | * $val should be null or a string. Other types are accepted for B/C with legacy |
| 227 | * code but can result in surprising behavior and are discouraged. Values are always |
| 228 | * stored as strings in the database, so if you pass a non-string value, it will be |
| 229 | * eventually converted; but before the call to saveOptions(), getOption() will return |
| 230 | * the passed value from instance cache without any type conversion. |
| 231 | * |
| 232 | * A null value means resetting the option to its default value (removing the user_properties |
| 233 | * row). Passing in the same value as the default value fo the user has the same result. |
| 234 | * This behavior supports some level of type juggling - e.g. if the default value is 1, |
| 235 | * and you pass in '1', the option will be reset to its default value. |
| 236 | * |
| 237 | * When an option is reset to its default value, that means whenever the default value |
| 238 | * is changed in the site configuration, the user preference for this user will also change. |
| 239 | * There is no way to set a user preference to be the same as the default but avoid it |
| 240 | * changing when the default changes. You can instead use $wgConditionalUserOptions to |
| 241 | * split the default based on user registration date. |
| 242 | * |
| 243 | * If a global user option exists with the given name, the behaviour depends on the value |
| 244 | * of $global. |
| 245 | * |
| 246 | * @param UserIdentity $user |
| 247 | * @param string $oname The option to set |
| 248 | * @param mixed $val New value to set. |
| 249 | * @param string $global Since 1.43. The global update behaviour, used if |
| 250 | * GlobalPreferences is installed: |
| 251 | * - GLOBAL_IGNORE: If there is a global preference, do nothing. The option remains with |
| 252 | * its previous value. |
| 253 | * - GLOBAL_OVERRIDE: If there is a global preference, add a local override. |
| 254 | * - GLOBAL_UPDATE: If there is a global preference, update it. |
| 255 | * - GLOBAL_CREATE: Create a new global preference, overriding any local value. |
| 256 | * The UI should typically ask for the user's consent before setting a global |
| 257 | * option. |
| 258 | */ |
| 259 | public function setOption( UserIdentity $user, string $oname, $val, |
| 260 | $global = self::GLOBAL_IGNORE |
| 261 | ) { |
| 262 | // Explicitly NULL values should refer to defaults |
| 263 | $val ??= $this->defaultOptionsLookup->getDefaultOption( $oname, $user ); |
| 264 | $userKey = $this->getCacheKey( $user ); |
| 265 | $info = $this->cache[$userKey] ??= new UserOptionsCacheEntry; |
| 266 | $info->modifiedValues[$oname] = $val; |
| 267 | $info->globalUpdateActions[$oname] = $global; |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * Reset a list of options to the site defaults |
| 272 | * |
| 273 | * @note You need to call saveOptions() to actually write to the database. |
| 274 | * |
| 275 | * @param UserIdentity $user |
| 276 | * @param string[] $optionNames |
| 277 | */ |
| 278 | public function resetOptionsByName( |
| 279 | UserIdentity $user, |
| 280 | array $optionNames |
| 281 | ) { |
| 282 | foreach ( $optionNames as $name ) { |
| 283 | $this->setOption( $user, $name, null ); |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | /** |
| 288 | * Reset all options that were set to a non-default value by the given user |
| 289 | * |
| 290 | * @note You need to call saveOptions() to actually write to the database. |
| 291 | * |
| 292 | * @param UserIdentity $user |
| 293 | */ |
| 294 | public function resetAllOptions( UserIdentity $user ) { |
| 295 | foreach ( $this->loadUserOptions( $user ) as $name => $value ) { |
| 296 | $this->setOption( $user, $name, null ); |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | /** |
| 301 | * Saves the non-default options for this user, as previously set e.g. via |
| 302 | * setOption(), in the database's "user_properties" (preferences) table. |
| 303 | * |
| 304 | * @since 1.38, this method was internal before that. |
| 305 | * @param UserIdentity $user |
| 306 | */ |
| 307 | public function saveOptions( UserIdentity $user ) { |
| 308 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
| 309 | $changed = $this->saveOptionsInternal( $user ); |
| 310 | $legacyUser = $this->userFactory->newFromUserIdentity( $user ); |
| 311 | // Before UserOptionsManager, User::saveSettings was used for user options |
| 312 | // saving. Some extensions might depend on UserSaveSettings hook being run |
| 313 | // when options are saved, so run this hook for legacy reasons. |
| 314 | // Once UserSaveSettings hook is deprecated and replaced with a different hook |
| 315 | // with more modern interface, extensions should use 'SaveUserOptions' hook. |
| 316 | $this->hookRunner->onUserSaveSettings( $legacyUser ); |
| 317 | if ( $changed ) { |
| 318 | $dbw->onTransactionCommitOrIdle( static function () use ( $legacyUser ) { |
| 319 | $legacyUser->getInstanceFromPrimary()?->checkAndSetTouched(); |
| 320 | }, __METHOD__ ); |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | /** |
| 325 | * Saves the non-default options for this user, as previously set e.g. via |
| 326 | * setOption(), in the database's "user_properties" (preferences) table. |
| 327 | * |
| 328 | * @param UserIdentity $user |
| 329 | * @return bool true if options were changed and new options successfully saved. |
| 330 | * @internal only public for use in User::saveSettings |
| 331 | */ |
| 332 | public function saveOptionsInternal( UserIdentity $user ): bool { |
| 333 | if ( $this->userNameUtils->isIP( $user->getName() ) || $this->userNameUtils->isTemp( $user->getName() ) ) { |
| 334 | throw new InvalidArgumentException( __METHOD__ . ' was called on IP or temporary user' ); |
| 335 | } |
| 336 | |
| 337 | $userKey = $this->getCacheKey( $user ); |
| 338 | $cache = $this->cache[$userKey] ?? new UserOptionsCacheEntry; |
| 339 | $modifiedOptions = $cache->modifiedValues; |
| 340 | |
| 341 | // FIXME: should probably use READ_LATEST here |
| 342 | $originalOptions = $this->loadOriginalOptions( $user ); |
| 343 | |
| 344 | if ( !$this->hookRunner->onSaveUserOptions( $user, $modifiedOptions, $originalOptions ) ) { |
| 345 | return false; |
| 346 | } |
| 347 | |
| 348 | $updatesByStore = []; |
| 349 | foreach ( $modifiedOptions as $key => $value ) { |
| 350 | // Don't store unchanged or default values |
| 351 | $defaultValue = $this->defaultOptionsLookup->getDefaultOption( $key, $user ); |
| 352 | if ( $value === null || self::isValueEqual( $value, $defaultValue ) ) { |
| 353 | $valOrNull = null; |
| 354 | } else { |
| 355 | $valOrNull = (string)$value; |
| 356 | } |
| 357 | $source = $cache->sources[$key] ?? self::LOCAL_STORE_KEY; |
| 358 | $updateAction = $cache->globalUpdateActions[$key] ?? self::GLOBAL_IGNORE; |
| 359 | |
| 360 | if ( $source === self::LOCAL_STORE_KEY ) { |
| 361 | if ( $updateAction === self::GLOBAL_CREATE ) { |
| 362 | $updatesByStore[$this->getStoreNameForGlobalCreate()][$key] = $valOrNull; |
| 363 | } else { |
| 364 | $updatesByStore[self::LOCAL_STORE_KEY][$key] = $valOrNull; |
| 365 | } |
| 366 | } else { |
| 367 | if ( $updateAction === self::GLOBAL_UPDATE || $updateAction === self::GLOBAL_CREATE ) { |
| 368 | $updatesByStore[$source][$key] = $valOrNull; |
| 369 | } elseif ( $updateAction === self::GLOBAL_OVERRIDE ) { |
| 370 | $updatesByStore[self::LOCAL_STORE_KEY][$key] = $valOrNull; |
| 371 | $updatesByStore[self::LOCAL_STORE_KEY][$key . self::LOCAL_EXCEPTION_SUFFIX] = '1'; |
| 372 | } |
| 373 | } |
| 374 | } |
| 375 | $changed = false; |
| 376 | $stores = $this->getStores(); |
| 377 | foreach ( $updatesByStore as $source => $updates ) { |
| 378 | $changed = $stores[$source]->store( $user, $updates ) || $changed; |
| 379 | } |
| 380 | |
| 381 | if ( !$changed ) { |
| 382 | return false; |
| 383 | } |
| 384 | |
| 385 | // Clear the cache and the update queue |
| 386 | unset( $this->cache[$userKey] ); |
| 387 | return true; |
| 388 | } |
| 389 | |
| 390 | /** |
| 391 | * Loads user options either from cache or from the database. |
| 392 | * |
| 393 | * @note Query flags are ignored for anons, since they do not have any |
| 394 | * options stored in the database. If the UserIdentity was itself |
| 395 | * obtained from a replica and doesn't have ID set due to replication lag, |
| 396 | * it will be treated as anon regardless of the query flags passed here. |
| 397 | * |
| 398 | * @internal |
| 399 | * |
| 400 | * @param UserIdentity $user |
| 401 | * @param int $queryFlags |
| 402 | * @return array |
| 403 | */ |
| 404 | public function loadUserOptions( |
| 405 | UserIdentity $user, |
| 406 | int $queryFlags = IDBAccessObject::READ_NORMAL |
| 407 | ): array { |
| 408 | $userKey = $this->getCacheKey( $user ); |
| 409 | $originalOptions = $this->loadOriginalOptions( $user, $queryFlags ); |
| 410 | $cache = $this->cache[$userKey] ?? null; |
| 411 | if ( $cache ) { |
| 412 | return array_merge( $originalOptions, $cache->modifiedValues ); |
| 413 | } else { |
| 414 | return $originalOptions; |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | /** |
| 419 | * Clears cached user options. |
| 420 | * @internal To be used by User::clearInstanceCache |
| 421 | * @param UserIdentity $user |
| 422 | */ |
| 423 | public function clearUserOptionsCache( UserIdentity $user ) { |
| 424 | unset( $this->cache[ $this->getCacheKey( $user ) ] ); |
| 425 | } |
| 426 | |
| 427 | /** |
| 428 | * Fetches the options directly from the database with no caches. |
| 429 | * |
| 430 | * @param UserIdentity $user |
| 431 | * @param int $queryFlags a bit field composed of READ_XXX flags |
| 432 | * @return array |
| 433 | */ |
| 434 | private function loadOptionsFromStore( |
| 435 | UserIdentity $user, |
| 436 | int $queryFlags |
| 437 | ): array { |
| 438 | $this->logger->debug( 'Loading options from database', |
| 439 | [ 'user_id' => $user->getId(), 'user_name' => $user->getName() ] ); |
| 440 | $mergedOptions = []; |
| 441 | $cache = $this->cache[ $this->getCacheKey( $user ) ] ??= new UserOptionsCacheEntry; |
| 442 | foreach ( $this->getStores() as $storeName => $store ) { |
| 443 | $options = $store->fetch( $user, $queryFlags ); |
| 444 | foreach ( $options as $name => $value ) { |
| 445 | // Handle a local exception which is the default |
| 446 | if ( str_ends_with( $name, self::LOCAL_EXCEPTION_SUFFIX ) && $value ) { |
| 447 | $baseName = substr( $name, 0, -strlen( self::LOCAL_EXCEPTION_SUFFIX ) ); |
| 448 | if ( !isset( $options[$baseName] ) ) { |
| 449 | // T368595: The source should always be set to local for local exceptions |
| 450 | $cache->sources[$baseName] = self::LOCAL_STORE_KEY; |
| 451 | unset( $mergedOptions[$baseName] ); |
| 452 | } |
| 453 | } |
| 454 | |
| 455 | // Handle a non-default option or non-default local exception |
| 456 | if ( !isset( $mergedOptions[$name] ) |
| 457 | || !empty( $options[$name . self::LOCAL_EXCEPTION_SUFFIX] ) |
| 458 | ) { |
| 459 | $cache->sources[$name] = $storeName; |
| 460 | $mergedOptions[$name] = $this->normalizeValueType( $value ); |
| 461 | } |
| 462 | } |
| 463 | } |
| 464 | return $mergedOptions; |
| 465 | } |
| 466 | |
| 467 | /** |
| 468 | * Convert '0' to 0. PHP's boolean conversion considers them both |
| 469 | * false, but e.g. JavaScript considers the former as true. |
| 470 | * |
| 471 | * @todo T54542 Somehow determine the desired type (string/int/bool) |
| 472 | * and convert all values here. |
| 473 | * |
| 474 | * @param string $value |
| 475 | * @return mixed |
| 476 | */ |
| 477 | private function normalizeValueType( $value ) { |
| 478 | if ( $value === '0' ) { |
| 479 | $value = 0; |
| 480 | } |
| 481 | return $value; |
| 482 | } |
| 483 | |
| 484 | /** |
| 485 | * Loads the original user options from the database and applies various transforms, |
| 486 | * like timecorrection. Runs hooks. |
| 487 | * |
| 488 | * @param UserIdentity $user |
| 489 | * @param int $queryFlags |
| 490 | * @return array |
| 491 | */ |
| 492 | private function loadOriginalOptions( |
| 493 | UserIdentity $user, |
| 494 | int $queryFlags = IDBAccessObject::READ_NORMAL |
| 495 | ): array { |
| 496 | $userKey = $this->getCacheKey( $user ); |
| 497 | $cache = $this->cache[$userKey] ??= new UserOptionsCacheEntry; |
| 498 | |
| 499 | // In case options were already loaded from the database before and no options |
| 500 | // changes were saved to the database, we can use the cached original options. |
| 501 | if ( $cache->canUseCachedValues( $queryFlags ) |
| 502 | && $cache->originalValues !== null |
| 503 | ) { |
| 504 | return $cache->originalValues; |
| 505 | } |
| 506 | |
| 507 | $defaultOptions = $this->defaultOptionsLookup->getDefaultOptions( $user ); |
| 508 | |
| 509 | if ( $this->userNameUtils->isIP( $user->getName() ) || $this->userNameUtils->isTemp( $user->getName() ) ) { |
| 510 | // For unlogged-in users, load language/variant options from request. |
| 511 | // There's no need to do it for logged-in users: they can set preferences, |
| 512 | // and handling of page content is done by $pageLang->getPreferredVariant() and such, |
| 513 | // so don't override user's choice (especially when the user chooses site default). |
| 514 | $variant = $this->languageConverterFactory->getLanguageConverter()->getDefaultVariant(); |
| 515 | $defaultOptions['variant'] = $variant; |
| 516 | $defaultOptions['language'] = $variant; |
| 517 | $cache->originalValues = $defaultOptions; |
| 518 | return $defaultOptions; |
| 519 | } |
| 520 | |
| 521 | $options = $this->loadOptionsFromStore( $user, $queryFlags ) + $defaultOptions; |
| 522 | |
| 523 | // Replace deprecated language codes |
| 524 | $options['language'] = LanguageCode::replaceDeprecatedCodes( $options['language'] ); |
| 525 | $options['variant'] = LanguageCode::replaceDeprecatedCodes( $options['variant'] ); |
| 526 | foreach ( LanguageConverter::$languagesWithVariants as $langCode ) { |
| 527 | $variant = "variant-$langCode"; |
| 528 | if ( isset( $options[$variant] ) ) { |
| 529 | $options[$variant] = LanguageCode::replaceDeprecatedCodes( $options[$variant] ); |
| 530 | } |
| 531 | } |
| 532 | |
| 533 | // Fix up timezone offset (Due to DST it can change from what was stored in the DB) |
| 534 | // ZoneInfo|offset|TimeZoneName |
| 535 | if ( isset( $options['timecorrection'] ) ) { |
| 536 | $options['timecorrection'] = ( new UserTimeCorrection( |
| 537 | $options['timecorrection'], |
| 538 | null, |
| 539 | $this->serviceOptions->get( MainConfigNames::LocalTZoffset ) |
| 540 | ) )->toString(); |
| 541 | } |
| 542 | |
| 543 | // Need to store what we have so far before the hook to prevent |
| 544 | // infinite recursion if the hook attempts to reload options |
| 545 | $cache->originalValues = $options; |
| 546 | $cache->recency = $queryFlags; |
| 547 | $this->hookRunner->onLoadUserOptions( $user, $options ); |
| 548 | $cache->originalValues = $options; |
| 549 | return $options; |
| 550 | } |
| 551 | |
| 552 | /** |
| 553 | * Determines whether two values are sufficiently similar that the database |
| 554 | * does not need to be updated to reflect the change. This is basically the |
| 555 | * same as comparing the result of Database::addQuotes(). |
| 556 | * |
| 557 | * @since 1.43 |
| 558 | * |
| 559 | * @param mixed $a |
| 560 | * @param mixed $b |
| 561 | * @return bool |
| 562 | */ |
| 563 | public static function isValueEqual( $a, $b ) { |
| 564 | // null is only equal to another null (T355086) |
| 565 | if ( $a === null || $b === null ) { |
| 566 | return $a === $b; |
| 567 | } |
| 568 | |
| 569 | if ( is_bool( $a ) ) { |
| 570 | $a = (int)$a; |
| 571 | } |
| 572 | if ( is_bool( $b ) ) { |
| 573 | $b = (int)$b; |
| 574 | } |
| 575 | return (string)$a === (string)$b; |
| 576 | } |
| 577 | |
| 578 | /** |
| 579 | * Get the storage backends in descending order of priority |
| 580 | * |
| 581 | * @return UserOptionsStore[] |
| 582 | */ |
| 583 | private function getStores() { |
| 584 | if ( !$this->stores ) { |
| 585 | $stores = [ |
| 586 | self::LOCAL_STORE_KEY => new LocalUserOptionsStore( $this->dbProvider, $this->hookRunner ) |
| 587 | ]; |
| 588 | foreach ( $this->storeProviders as $name => $spec ) { |
| 589 | $store = $this->objectFactory->createObject( |
| 590 | $spec, |
| 591 | [ 'assertClass' => UserOptionsStore::class ] |
| 592 | ); |
| 593 | $stores[$name] = $store; |
| 594 | } |
| 595 | // Query global providers first, preserve keys |
| 596 | $this->stores = array_reverse( $stores, true ); |
| 597 | } |
| 598 | return $this->stores; |
| 599 | } |
| 600 | |
| 601 | /** |
| 602 | * Get the name of the store to be used when setOption() is called with |
| 603 | * GLOBAL_CREATE and there is no existing global preference value. |
| 604 | * |
| 605 | * @return string |
| 606 | */ |
| 607 | private function getStoreNameForGlobalCreate() { |
| 608 | foreach ( $this->getStores() as $name => $store ) { |
| 609 | if ( $name !== self::LOCAL_STORE_KEY ) { |
| 610 | return $name; |
| 611 | } |
| 612 | } |
| 613 | return self::LOCAL_STORE_KEY; |
| 614 | } |
| 615 | } |
| 616 | |
| 617 | /** @deprecated class alias since 1.42 */ |
| 618 | class_alias( UserOptionsManager::class, 'MediaWiki\\User\\UserOptionsManager' ); |