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