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