wikimedia/mediawiki-core

View on GitHub
includes/user/Options/UserOptionsManager.php

Summary

Maintainability
D
2 days
Test Coverage
<?php
/**
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
 * http://www.gnu.org/copyleft/gpl.html
 *
 * @file
 */

namespace MediaWiki\User\Options;

use InvalidArgumentException;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Context\IContextSource;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Language\LanguageCode;
use MediaWiki\Language\LanguageConverter;
use MediaWiki\Languages\LanguageConverterFactory;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserIdentity;
use MediaWiki\User\UserNameUtils;
use MediaWiki\User\UserTimeCorrection;
use Psr\Log\LoggerInterface;
use Wikimedia\ObjectFactory\ObjectFactory;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\Rdbms\IDBAccessObject;

/**
 * A service class to control user options
 * @since 1.35
 */
class UserOptionsManager extends UserOptionsLookup {

    /**
     * @internal For use by ServiceWiring
     */
    public const CONSTRUCTOR_OPTIONS = [
        MainConfigNames::HiddenPrefs,
        MainConfigNames::LocalTZoffset,
    ];

    /**
     * @since 1.39.5, 1.40
     */
    public const MAX_BYTES_OPTION_VALUE = 65530;

    /**
     * If the option was set globally, ignore the update.
     * @since 1.43
     */
    public const GLOBAL_IGNORE = 'ignore';

    /**
     * If the option was set globally, add a local override.
     * @since 1.43
     */
    public const GLOBAL_OVERRIDE = 'override';

    /**
     * If the option was set globally, update the global value.
     * @since 1.43
     */
    public const GLOBAL_UPDATE = 'update';

    private const LOCAL_STORE_KEY = 'local';

    private ServiceOptions $serviceOptions;
    private DefaultOptionsLookup $defaultOptionsLookup;
    private LanguageConverterFactory $languageConverterFactory;
    private IConnectionProvider $dbProvider;
    private UserFactory $userFactory;
    private LoggerInterface $logger;
    private HookRunner $hookRunner;
    private UserNameUtils $userNameUtils;
    private array $storeProviders;

    private ObjectFactory $objectFactory;

    /** @var UserOptionsCacheEntry[] */
    private $cache = [];

    /** @var UserOptionsStore[]|null */
    private $stores;

    /**
     * @param ServiceOptions $options
     * @param DefaultOptionsLookup $defaultOptionsLookup
     * @param LanguageConverterFactory $languageConverterFactory
     * @param IConnectionProvider $dbProvider
     * @param LoggerInterface $logger
     * @param HookContainer $hookContainer
     * @param UserFactory $userFactory
     * @param UserNameUtils $userNameUtils
     * @param ObjectFactory $objectFactory
     * @param array $storeProviders
     */
    public function __construct(
        ServiceOptions $options,
        DefaultOptionsLookup $defaultOptionsLookup,
        LanguageConverterFactory $languageConverterFactory,
        IConnectionProvider $dbProvider,
        LoggerInterface $logger,
        HookContainer $hookContainer,
        UserFactory $userFactory,
        UserNameUtils $userNameUtils,
        ObjectFactory $objectFactory,
        array $storeProviders
    ) {
        $options->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;
    }

    /**
     * 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. What to do if the option was set
     *   globally using the GlobalPreferences extension. One of the
     *   self::GLOBAL_* constants:
     *   - GLOBAL_IGNORE: Do nothing. The option remains with its previous value.
     *   - GLOBAL_OVERRIDE: Add a local override.
     *   - GLOBAL_UPDATE: Update the option globally.
     *   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 certain (or all) options to the site defaults
     *
     * The optional parameter determines which kinds of preferences will be reset.
     * Supported values are everything that can be reported by getOptionKinds()
     * and 'all', which forces a reset of *all* preferences and overrides everything else.
     *
     * @note You need to call saveOptions() to actually write to the database.
     *
     * @deprecated since 1.43 use resetOptionsByName() with PreferencesFactory::getOptionNamesForReset()
     *
     * @param UserIdentity $user
     * @param IContextSource $context Context source used when $resetKinds does not contain 'all'.
     * @param array|string $resetKinds Which kinds of preferences to reset.
     *  Defaults to [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ]
     */
    public function resetOptions(
        UserIdentity $user,
        IContextSource $context,
        $resetKinds = [ 'registered', 'registered-multiselect', 'registered-checkmatrix', 'unused' ]
    ) {
        wfDeprecated( __METHOD__, '1.43' );
        $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory();
        $optionsToReset = $preferencesFactory->getOptionNamesForReset(
            $this->userFactory->newFromUserIdentity( $user ), $context, $resetKinds );
        $this->resetOptionsByName( $user, $optionsToReset );
    }

    /**
     * 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 );
        }
    }

    /**
     * @deprecated since 1.43 use PreferencesFactory::listResetKinds()
     *
     * @return string[] Option kinds
     */
    public function listOptionKinds(): array {
        wfDeprecated( __METHOD__, '1.43' );
        $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory();
        return $preferencesFactory->listResetKinds();
    }

    /**
     * @deprecated since 1.43 use PreferencesFactory::getResetKinds
     *
     * @param UserIdentity $userIdentity
     * @param IContextSource $context
     * @param array|null $options
     * @return string[]
     */
    public function getOptionKinds(
        UserIdentity $userIdentity,
        IContextSource $context,
        $options = null
    ): array {
        wfDeprecated( __METHOD__, '1.43' );
        $user = $this->userFactory->newFromUserIdentity( $userIdentity );
        $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory();
        return $preferencesFactory->getResetKinds( $user, $context, $options );
    }

    /**
     * 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;
            if ( $source === self::LOCAL_STORE_KEY ) {
                $updatesByStore[self::LOCAL_STORE_KEY][$key] = $valOrNull;
            } else {
                $updateAction = $cache->globalUpdateActions[$key] ?? self::GLOBAL_IGNORE;
                if ( $updateAction === self::GLOBAL_UPDATE ) {
                    $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 );
        $defaultOptions = $this->defaultOptionsLookup->getDefaultOptions( $user );
        $cache = $this->cache[$userKey] ??= new UserOptionsCacheEntry;
        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;
        }

        // 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;
        }

        $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;
    }

    /**
     * Get a cache key for a user
     * @param UserIdentity $user
     * @return string
     */
    private function getCacheKey( UserIdentity $user ): string {
        $name = $user->getName();
        if ( $this->userNameUtils->isIP( $name ) || $this->userNameUtils->isTemp( $name ) ) {
            // IP and temporary users may not have custom preferences, so they can share a key
            return 'anon';
        } elseif ( $user->isRegistered() ) {
            return "u:{$user->getId()}";
        } else {
            // Allow users with no local account to have preferences provided by alternative
            // UserOptionsStore implementations (e.g. in GlobalPreferences)
            $canonical = $this->userNameUtils->getCanonical( $name ) ?: $name;
            return "a:$canonical";
        }
    }

    /**
     * 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;
    }
}

/** @deprecated class alias since 1.42 */
class_alias( UserOptionsManager::class, 'MediaWiki\\User\\UserOptionsManager' );