assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); $this->serviceOptions = $options; $this->defaultOptionsLookup = $defaultOptionsLookup; $this->languageConverterFactory = $languageConverterFactory; $this->dbProvider = $dbProvider; $this->logger = $logger; $this->hookRunner = new HookRunner( $hookContainer ); $this->userFactory = $userFactory; $this->userNameUtils = $userNameUtils; $this->objectFactory = $objectFactory; $this->storeProviders = $storeProviders; } /** * @inheritDoc */ public function getDefaultOptions( ?UserIdentity $userIdentity = null ): array { return $this->defaultOptionsLookup->getDefaultOptions( $userIdentity ); } /** * @inheritDoc */ public function getDefaultOption( string $opt, ?UserIdentity $userIdentity = null ) { return $this->defaultOptionsLookup->getDefaultOption( $opt, $userIdentity ); } /** * @inheritDoc */ public function getOption( UserIdentity $user, string $oname, $defaultOverride = null, bool $ignoreHidden = false, int $queryFlags = IDBAccessObject::READ_NORMAL ) { # We want 'disabled' preferences to always behave as the default value for # users, even if they have set the option explicitly in their settings (ie they # set it, and then it was disabled removing their ability to change it). But # we don't want to erase the preferences in the database in case the preference # is re-enabled again. So don't touch $mOptions, just override the returned value if ( !$ignoreHidden && in_array( $oname, $this->serviceOptions->get( MainConfigNames::HiddenPrefs ) ) ) { return $this->defaultOptionsLookup->getDefaultOption( $oname, $user ); } $options = $this->loadUserOptions( $user, $queryFlags ); if ( array_key_exists( $oname, $options ) ) { return $options[$oname]; } return $defaultOverride; } /** * @inheritDoc */ public function getOptions( UserIdentity $user, int $flags = 0, int $queryFlags = IDBAccessObject::READ_NORMAL ): array { $options = $this->loadUserOptions( $user, $queryFlags ); # We want 'disabled' preferences to always behave as the default value for # users, even if they have set the option explicitly in their settings (ie they # set it, and then it was disabled removing their ability to change it). But # we don't want to erase the preferences in the database in case the preference # is re-enabled again. So don't touch $mOptions, just override the returned value foreach ( $this->serviceOptions->get( MainConfigNames::HiddenPrefs ) as $pref ) { $default = $this->defaultOptionsLookup->getDefaultOption( $pref, $user ); if ( $default !== null ) { $options[$pref] = $default; } } if ( $flags & self::EXCLUDE_DEFAULTS ) { // NOTE: This intentionally ignores conditional defaults, so that `mw.user.options` // work correctly for options with conditional defaults. $defaultOptions = $this->defaultOptionsLookup->getDefaultOptions( null ); foreach ( $options as $option => $value ) { if ( array_key_exists( $option, $defaultOptions ) && self::isValueEqual( $value, $defaultOptions[$option] ) ) { unset( $options[$option] ); } } } return $options; } public function isOptionGlobal( UserIdentity $user, string $key ) { $this->getOptions( $user ); $source = $this->cache[ $this->getCacheKey( $user ) ]->sources[$key] ?? self::LOCAL_STORE_KEY; return $source !== self::LOCAL_STORE_KEY; } public function getOptionBatchForUserNames( array $users, string $key ) { if ( !$users ) { return []; } $exceptionKey = $key . self::LOCAL_EXCEPTION_SUFFIX; $results = []; $stores = $this->getStores(); foreach ( $stores as $storeName => $store ) { // Check the exception key in the local store, if there is more than one store if ( count( $stores ) > 1 && $storeName === self::LOCAL_STORE_KEY ) { $storeResults = $store->fetchBatchForUserNames( [ $key, $exceptionKey ], $users ); $values = $storeResults[$key] ?? []; $exceptions = $storeResults[$exceptionKey] ?? []; foreach ( $values as $userName => $value ) { if ( !empty( $exceptions[$userName] ) || !isset( $results[$userName] ) ) { $results[$userName] = $value; } } } else { $storeResults = $store->fetchBatchForUserNames( [ $key ], $users ); $results += $storeResults[$key] ?? []; } } // If $key has a conditional default, DefaultOptionsLookup will be expensive, // so it makes sense to only ask for the users without an option set. $usersNeedingDefaults = array_diff( $users, array_keys( $results ) ); if ( $usersNeedingDefaults ) { $defaults = $this->defaultOptionsLookup->getOptionBatchForUserNames( $usersNeedingDefaults, $key ); $results += $defaults; } return $results; } /** * Set the given option for a user. * * You need to call saveOptions() to actually write to the database. * * $val should be null or a string. Other types are accepted for B/C with legacy * code but can result in surprising behavior and are discouraged. Values are always * stored as strings in the database, so if you pass a non-string value, it will be * eventually converted; but before the call to saveOptions(), getOption() will return * the passed value from instance cache without any type conversion. * * A null value means resetting the option to its default value (removing the user_properties * row). Passing in the same value as the default value fo the user has the same result. * This behavior supports some level of type juggling - e.g. if the default value is 1, * and you pass in '1', the option will be reset to its default value. * * When an option is reset to its default value, that means whenever the default value * is changed in the site configuration, the user preference for this user will also change. * There is no way to set a user preference to be the same as the default but avoid it * changing when the default changes. You can instead use $wgConditionalUserOptions to * split the default based on user registration date. * * If a global user option exists with the given name, the behaviour depends on the value * of $global. * * @param UserIdentity $user * @param string $oname The option to set * @param mixed $val New value to set. * @param string $global Since 1.43. The global update behaviour, used if * GlobalPreferences is installed: * - GLOBAL_IGNORE: If there is a global preference, do nothing. The option remains with * its previous value. * - GLOBAL_OVERRIDE: If there is a global preference, add a local override. * - GLOBAL_UPDATE: If there is a global preference, update it. * - GLOBAL_CREATE: Create a new global preference, overriding any local value. * The UI should typically ask for the user's consent before setting a global * option. */ public function setOption( UserIdentity $user, string $oname, $val, $global = self::GLOBAL_IGNORE ) { // Explicitly NULL values should refer to defaults $val ??= $this->defaultOptionsLookup->getDefaultOption( $oname, $user ); $userKey = $this->getCacheKey( $user ); $info = $this->cache[$userKey] ??= new UserOptionsCacheEntry; $info->modifiedValues[$oname] = $val; $info->globalUpdateActions[$oname] = $global; } /** * Reset a list of options to the site defaults * * @note You need to call saveOptions() to actually write to the database. * * @param UserIdentity $user * @param string[] $optionNames */ public function resetOptionsByName( UserIdentity $user, array $optionNames ) { foreach ( $optionNames as $name ) { $this->setOption( $user, $name, null ); } } /** * Reset all options that were set to a non-default value by the given user * * @note You need to call saveOptions() to actually write to the database. * * @param UserIdentity $user */ public function resetAllOptions( UserIdentity $user ) { foreach ( $this->loadUserOptions( $user ) as $name => $value ) { $this->setOption( $user, $name, null ); } } /** * Saves the non-default options for this user, as previously set e.g. via * setOption(), in the database's "user_properties" (preferences) table. * * @since 1.38, this method was internal before that. * @param UserIdentity $user */ public function saveOptions( UserIdentity $user ) { $dbw = $this->dbProvider->getPrimaryDatabase(); $changed = $this->saveOptionsInternal( $user ); $legacyUser = $this->userFactory->newFromUserIdentity( $user ); // Before UserOptionsManager, User::saveSettings was used for user options // saving. Some extensions might depend on UserSaveSettings hook being run // when options are saved, so run this hook for legacy reasons. // Once UserSaveSettings hook is deprecated and replaced with a different hook // with more modern interface, extensions should use 'SaveUserOptions' hook. $this->hookRunner->onUserSaveSettings( $legacyUser ); if ( $changed ) { $dbw->onTransactionCommitOrIdle( static function () use ( $legacyUser ) { $legacyUser->checkAndSetTouched(); }, __METHOD__ ); } } /** * Saves the non-default options for this user, as previously set e.g. via * setOption(), in the database's "user_properties" (preferences) table. * * @param UserIdentity $user * @return bool true if options were changed and new options successfully saved. * @internal only public for use in User::saveSettings */ public function saveOptionsInternal( UserIdentity $user ): bool { if ( $this->userNameUtils->isIP( $user->getName() ) || $this->userNameUtils->isTemp( $user->getName() ) ) { throw new InvalidArgumentException( __METHOD__ . ' was called on IP or temporary user' ); } $userKey = $this->getCacheKey( $user ); $cache = $this->cache[$userKey] ?? new UserOptionsCacheEntry; $modifiedOptions = $cache->modifiedValues; // FIXME: should probably use READ_LATEST here $originalOptions = $this->loadOriginalOptions( $user ); if ( !$this->hookRunner->onSaveUserOptions( $user, $modifiedOptions, $originalOptions ) ) { return false; } $updatesByStore = []; foreach ( $modifiedOptions as $key => $value ) { // Don't store unchanged or default values $defaultValue = $this->defaultOptionsLookup->getDefaultOption( $key, $user ); if ( $value === null || self::isValueEqual( $value, $defaultValue ) ) { $valOrNull = null; } else { $valOrNull = (string)$value; } $source = $cache->sources[$key] ?? self::LOCAL_STORE_KEY; $updateAction = $cache->globalUpdateActions[$key] ?? self::GLOBAL_IGNORE; if ( $source === self::LOCAL_STORE_KEY ) { if ( $updateAction === self::GLOBAL_CREATE ) { $updatesByStore[$this->getStoreNameForGlobalCreate()][$key] = $valOrNull; } else { $updatesByStore[self::LOCAL_STORE_KEY][$key] = $valOrNull; } } else { if ( $updateAction === self::GLOBAL_UPDATE || $updateAction === self::GLOBAL_CREATE ) { $updatesByStore[$source][$key] = $valOrNull; } elseif ( $updateAction === self::GLOBAL_OVERRIDE ) { $updatesByStore[self::LOCAL_STORE_KEY][$key] = $valOrNull; $updatesByStore[self::LOCAL_STORE_KEY][$key . self::LOCAL_EXCEPTION_SUFFIX] = '1'; } } } $changed = false; $stores = $this->getStores(); foreach ( $updatesByStore as $source => $updates ) { $changed = $stores[$source]->store( $user, $updates ) || $changed; } if ( !$changed ) { return false; } // Clear the cache and the update queue unset( $this->cache[$userKey] ); return true; } /** * Loads user options either from cache or from the database. * * @note Query flags are ignored for anons, since they do not have any * options stored in the database. If the UserIdentity was itself * obtained from a replica and doesn't have ID set due to replication lag, * it will be treated as anon regardless of the query flags passed here. * * @internal * * @param UserIdentity $user * @param int $queryFlags * @return array */ public function loadUserOptions( UserIdentity $user, int $queryFlags = IDBAccessObject::READ_NORMAL ): array { $userKey = $this->getCacheKey( $user ); $originalOptions = $this->loadOriginalOptions( $user, $queryFlags ); $cache = $this->cache[$userKey] ?? null; if ( $cache ) { return array_merge( $originalOptions, $cache->modifiedValues ); } else { return $originalOptions; } } /** * Clears cached user options. * @internal To be used by User::clearInstanceCache * @param UserIdentity $user */ public function clearUserOptionsCache( UserIdentity $user ) { unset( $this->cache[ $this->getCacheKey( $user ) ] ); } /** * Fetches the options directly from the database with no caches. * * @param UserIdentity $user * @param int $queryFlags a bit field composed of READ_XXX flags * @return array */ private function loadOptionsFromStore( UserIdentity $user, int $queryFlags ): array { $this->logger->debug( 'Loading options from database', [ 'user_id' => $user->getId(), 'user_name' => $user->getName() ] ); $mergedOptions = []; $cache = $this->cache[ $this->getCacheKey( $user ) ] ??= new UserOptionsCacheEntry; foreach ( $this->getStores() as $storeName => $store ) { $options = $store->fetch( $user, $queryFlags ); foreach ( $options as $name => $value ) { // Handle a local exception which is the default if ( str_ends_with( $name, self::LOCAL_EXCEPTION_SUFFIX ) && $value ) { $baseName = substr( $name, 0, -strlen( self::LOCAL_EXCEPTION_SUFFIX ) ); if ( !isset( $options[$baseName] ) ) { // T368595: The source should always be set to local for local exceptions $cache->sources[$baseName] = self::LOCAL_STORE_KEY; unset( $mergedOptions[$baseName] ); } } // Handle a non-default option or non-default local exception if ( !isset( $mergedOptions[$name] ) || !empty( $options[$name . self::LOCAL_EXCEPTION_SUFFIX] ) ) { $cache->sources[$name] = $storeName; $mergedOptions[$name] = $this->normalizeValueType( $value ); } } } return $mergedOptions; } /** * Convert '0' to 0. PHP's boolean conversion considers them both * false, but e.g. JavaScript considers the former as true. * * @todo T54542 Somehow determine the desired type (string/int/bool) * and convert all values here. * * @param string $value * @return mixed */ private function normalizeValueType( $value ) { if ( $value === '0' ) { $value = 0; } return $value; } /** * Loads the original user options from the database and applies various transforms, * like timecorrection. Runs hooks. * * @param UserIdentity $user * @param int $queryFlags * @return array */ private function loadOriginalOptions( UserIdentity $user, int $queryFlags = IDBAccessObject::READ_NORMAL ): array { $userKey = $this->getCacheKey( $user ); $cache = $this->cache[$userKey] ??= new UserOptionsCacheEntry; // In case options were already loaded from the database before and no options // changes were saved to the database, we can use the cached original options. if ( $cache->canUseCachedValues( $queryFlags ) && $cache->originalValues !== null ) { return $cache->originalValues; } $defaultOptions = $this->defaultOptionsLookup->getDefaultOptions( $user ); if ( $this->userNameUtils->isIP( $user->getName() ) || $this->userNameUtils->isTemp( $user->getName() ) ) { // For unlogged-in users, load language/variant options from request. // There's no need to do it for logged-in users: they can set preferences, // and handling of page content is done by $pageLang->getPreferredVariant() and such, // so don't override user's choice (especially when the user chooses site default). $variant = $this->languageConverterFactory->getLanguageConverter()->getDefaultVariant(); $defaultOptions['variant'] = $variant; $defaultOptions['language'] = $variant; $cache->originalValues = $defaultOptions; return $defaultOptions; } $options = $this->loadOptionsFromStore( $user, $queryFlags ) + $defaultOptions; // Replace deprecated language codes $options['language'] = LanguageCode::replaceDeprecatedCodes( $options['language'] ); $options['variant'] = LanguageCode::replaceDeprecatedCodes( $options['variant'] ); foreach ( LanguageConverter::$languagesWithVariants as $langCode ) { $variant = "variant-$langCode"; if ( isset( $options[$variant] ) ) { $options[$variant] = LanguageCode::replaceDeprecatedCodes( $options[$variant] ); } } // Fix up timezone offset (Due to DST it can change from what was stored in the DB) // ZoneInfo|offset|TimeZoneName if ( isset( $options['timecorrection'] ) ) { $options['timecorrection'] = ( new UserTimeCorrection( $options['timecorrection'], null, $this->serviceOptions->get( MainConfigNames::LocalTZoffset ) ) )->toString(); } // Need to store what we have so far before the hook to prevent // infinite recursion if the hook attempts to reload options $cache->originalValues = $options; $cache->recency = $queryFlags; $this->hookRunner->onLoadUserOptions( $user, $options ); $cache->originalValues = $options; return $options; } /** * Determines whether two values are sufficiently similar that the database * does not need to be updated to reflect the change. This is basically the * same as comparing the result of Database::addQuotes(). * * @since 1.43 * * @param mixed $a * @param mixed $b * @return bool */ public static function isValueEqual( $a, $b ) { // null is only equal to another null (T355086) if ( $a === null || $b === null ) { return $a === $b; } if ( is_bool( $a ) ) { $a = (int)$a; } if ( is_bool( $b ) ) { $b = (int)$b; } return (string)$a === (string)$b; } /** * Get the storage backends in descending order of priority * * @return UserOptionsStore[] */ private function getStores() { if ( !$this->stores ) { $stores = [ self::LOCAL_STORE_KEY => new LocalUserOptionsStore( $this->dbProvider ) ]; foreach ( $this->storeProviders as $name => $spec ) { $store = $this->objectFactory->createObject( $spec ); if ( !$store instanceof UserOptionsStore ) { throw new \RuntimeException( "Invalid type for extension store \"$name\"" ); } $stores[$name] = $store; } // Query global providers first, preserve keys $this->stores = array_reverse( $stores, true ); } return $this->stores; } /** * Get the name of the store to be used when setOption() is called with * GLOBAL_CREATE and there is no existing global preference value. * * @return string */ private function getStoreNameForGlobalCreate() { foreach ( $this->getStores() as $name => $store ) { if ( $name !== self::LOCAL_STORE_KEY ) { return $name; } } return self::LOCAL_STORE_KEY; } } /** @deprecated class alias since 1.42 */ class_alias( UserOptionsManager::class, 'MediaWiki\\User\\UserOptionsManager' );