Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
42.86% covered (danger)
42.86%
9 / 21
CRAP
69.54% covered (warning)
69.54%
137 / 197
GlobalPreferencesFactory
0.00% covered (danger)
0.00%
0 / 1
42.86% covered (danger)
42.86%
9 / 21
266.36
69.54% covered (warning)
69.54%
137 / 197
 setAutoGlobals
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 getFormDescriptor
0.00% covered (danger)
0.00%
0 / 1
6.00
50.00% covered (danger)
50.00%
5 / 10
 getCache
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
3 / 3
 getPreferencesLocal
0.00% covered (danger)
0.00%
0 / 1
5.00
94.74% covered (success)
94.74%
36 / 38
 getPreferencesGlobal
0.00% covered (danger)
0.00%
0 / 1
72
0.00% covered (danger)
0.00%
0 / 32
 saveFormData
0.00% covered (danger)
0.00%
0 / 1
15.34
88.57% covered (warning)
88.57%
31 / 35
 findCheckMatrices
0.00% covered (danger)
0.00%
0 / 1
42
0.00% covered (danger)
0.00%
0 / 6
 getSectionFragmentId
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 isGlobalizablePreference
100.00% covered (success)
100.00%
1 / 1
10
100.00% covered (success)
100.00%
12 / 12
 isGlobalPrefName
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 isLocalPrefName
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 1
 strEndsWith
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 isUserGlobalized
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
1 / 1
 getUserID
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
5 / 5
 getGlobalPreferencesValues
0.00% covered (danger)
0.00%
0 / 1
2.31
57.14% covered (warning)
57.14%
4 / 7
 setGlobalPreferences
0.00% covered (danger)
0.00%
0 / 1
2.01
88.89% covered (warning)
88.89%
8 / 9
 resetGlobalUserSettings
0.00% covered (danger)
0.00%
0 / 1
2
0.00% covered (danger)
0.00%
0 / 2
 onGlobalPrefsPage
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
2 / 2
 onLocalPrefsPage
0.00% covered (danger)
0.00%
0 / 1
12
0.00% covered (danger)
0.00%
0 / 2
 handleLocalPreferencesChange
0.00% covered (danger)
0.00%
0 / 1
10
95.24% covered (success)
95.24%
20 / 21
 makeStorage
0.00% covered (danger)
0.00%
0 / 1
2.06
75.00% covered (warning)
75.00%
3 / 4
<?php
/**
 * Implements global preferences for MediaWiki
 *
 * @author Kunal Mehta <legoktm@gmail.com>
 * @license http://www.gnu.org/copyleft/gpl.html GPL-2.0-or-later
 * @file
 * @ingroup Extensions
 *
 * Partially based off of work by Werdna
 * https://www.mediawiki.org/wiki/Special:Code/MediaWiki/49790
 */
namespace GlobalPreferences;
use CentralIdLookup;
use Exception;
use IContextSource;
use LogicException;
use MapCacheLRU;
use MediaWiki\MediaWikiServices;
use MediaWiki\Preferences\DefaultPreferencesFactory;
use MediaWiki\User\UserIdentity;
use OOUI\ButtonWidget;
use RequestContext;
use SpecialPage;
use Status;
use User;
/**
 * Global preferences.
 * @package GlobalPreferences
 */
class GlobalPreferencesFactory extends DefaultPreferencesFactory {
    /**
     * The suffix appended to preference names
     * for the associated preference that tracks whether they have a local override.
     */
    public const LOCAL_EXCEPTION_SUFFIX = '-local-exception';
    /**
     * The suffix appended to preference names for their global counterparts.
     */
    public const GLOBAL_EXCEPTION_SUFFIX = '-global';
    /** @var MapCacheLRU Runtime cache of users' central IDs. */
    protected $centralIds;
    /**
     * @var string[] Names of autoglobal options
     */
    protected $autoGlobals = [];
    /**
     * "bad" preferences that we should remove from
     * Special:GlobalPrefs
     * @var array
     */
    protected $disallowedPreferences = [
        // Stored in user table, doesn't work yet
        'realname',
        // @todo Show CA user id / shared user table id?
        'userid',
        // @todo Show CA global groups instead?
        'usergroups',
        // @todo Should global edit count instead?
        'editcount',
        'registrationdate',
        // Signature could be global, but links in it are too likely to break.
        'nickname',
        'fancysig',
    ];
    /**
     * Preference types that we should not add a checkbox for
     * @var array
     */
    protected $typesPrevented = [
        'info',
        'hidden',
        'api',
    ];
    /**
     * Preference classes that are allowed to be global
     * @var array
     */
    protected $allowedClasses = [
        \HTMLSelectOrOtherField::class,
        \MediaWiki\Extension\BetaFeatures\HTMLFeatureField::class,
        \HTMLCheckMatrix::class,
    ];
    /**
     * Sets the list of options for which setting the local value should transparently update
     * the global value.
     *
     * @param string[] $list
     */
    public function setAutoGlobals( array $list ) {
        $this->autoGlobals = $list;
    }
    /**
     * Get all user preferences.
     * @param User $user
     * @param IContextSource $context The current request context
     * @return array|null
     */
    public function getFormDescriptor( User $user, IContextSource $context ) {
        $globalPrefs = $this->getGlobalPreferencesValues( $user, Storage::SKIP_CACHE );
        // The above function can return false
        $globalPrefNames = $globalPrefs ? array_keys( $globalPrefs ) : [];
        $preferences = parent::getFormDescriptor( $user, $context );
        if ( $this->onGlobalPrefsPage( $context ) ) {
            if ( $globalPrefs === false ) {
                throw new Exception(
                    "Attempted to load global preferences page for {$user->getName()} whose "
                    . 'preference values failed to load'
                );
            }
            return $this->getPreferencesGlobal( $user, $preferences, $globalPrefs, $context );
        }
        return $this->getPreferencesLocal( $user, $preferences, $globalPrefNames, $context );
    }
    /**
     * Lazy-init getter for central ID instance cache
     * @return MapCacheLRU
     */
    protected function getCache() {
        if ( !$this->centralIds ) {
            // Max of 20 is arbitrary and matches what CentralAuth uses.
            $this->centralIds = new MapCacheLRU( 20 );
        }
        return $this->centralIds;
    }
    /**
     * Add help-text to the local preferences where they're globalized,
     * and add the link to Special:GlobalPreferences to the personal preferences tab.
     * @param User $user
     * @param mixed[][] $preferences The preferences array.
     * @param string[] $globalPrefNames The names of those preferences that are already global.
     * @param IContextSource $context The current request context
     * @return mixed[][]
     */
    protected function getPreferencesLocal(
        User $user,
        array $preferences,
        array $globalPrefNames,
        IContextSource $context
    ) {
        $this->logger->debug( "Creating local preferences array for '{$user->getName()}'" );
        $modifiedPrefs = [];
        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
        foreach ( $preferences as $name => $def ) {
            $modifiedPrefs[$name] = $def;
            // If this is an api preference or hasn't been set globally.
            if ( !isset( $def['section'] ) || !in_array( $name, $globalPrefNames ) ) {
                continue;
            }
            $localExName = $name . static::LOCAL_EXCEPTION_SUFFIX;
            $localExValueUser = $userOptionsLookup->getBoolOption( $user, $localExName );
            // Add a new local exception preference after this one.
            $cssClasses = [
                'mw-globalprefs-local-exception',
                'mw-globalprefs-local-exception-for-' . $name,
            ];
            $section = $def['section'];
            $secFragment = static::getSectionFragmentId( $section );
            $labelMsg = $context->msg( 'globalprefs-set-local-exception', [ $secFragment ] );
            $modifiedPrefs[$localExName] = [
                'type' => 'toggle',
                'label-raw' => $labelMsg->parse(),
                'default' => $localExValueUser,
                'section' => $section,
                'cssclass' => implode( ' ', $cssClasses ),
                'hide-if' => $def['hide-if'] ?? false,
                'disable-if' => $def['disable-if'] ?? false,
            ];
            if ( isset( $def['disable-if'] ) ) {
                $modifiedPrefs[$name]['disable-if'] = [ 'OR', $def['disable-if'],
                    [ '!==', $localExName, '1' ]
                ];
            } else {
                $modifiedPrefs[$name]['disable-if'] = [ '!==', $localExName, '1' ];
            }
        }
        $preferences = $modifiedPrefs;
        // Add a link to GlobalPreferences to the local preferences form.
        $linkObject = new ButtonWidget( [
            'href' => SpecialPage::getTitleFor( 'GlobalPreferences' )->getLinkURL(),
            'label' => $context->msg( 'globalprefs-info-link' )->text(),
        ] );
        $link = $linkObject->toString();
        $preferences['global-info'] = [
            'type' => 'info',
            'section' => 'personal/info',
            'label-message' => 'globalprefs-info-label',
            'raw' => true,
            'default' => $link,
            'help-message' => 'globalprefs-info-help',
        ];
        return $preferences;
    }
    /**
     * Add the '-global' counterparts to all preferences, and override the local exception.
     * @param User $user
     * @param mixed[][] $preferences The preferences array.
     * @param mixed[] $globalPrefs The array of global preferences.
     * @param IContextSource $context The current request context
     * @return mixed[][]
     */
    protected function getPreferencesGlobal(
        User $user,
        array $preferences,
        array $globalPrefs,
        IContextSource $context
    ) {
        // Add all corresponding new global fields.
        $allPrefs = [];
        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
        foreach ( $preferences as $pref => $def ) {
            // Ignore unwanted preferences.
            if ( !$this->isGlobalizablePreference( $pref, $def ) ) {
                continue;
            }
            // Create the new preference.
            $isGlobal = isset( $globalPrefs[$pref] );
            $allPrefs[$pref . static::GLOBAL_EXCEPTION_SUFFIX] = [
                'type' => 'toggle',
                // Make the tooltip and the label the same, because the label is normally hidden.
                'tooltip' => 'globalprefs-check-label',
                'label-message' => 'tooltip-globalprefs-check-label',
                'default' => $isGlobal,
                'section' => $def['section'],
                'cssclass' => 'mw-globalprefs-global-check mw-globalprefs-checkbox-for-' . $pref,
                'hide-if' => $def['hide-if'] ?? false,
                'disable-if' => $def['disable-if'] ?? false,
            ];
            if ( isset( $def['disable-if'] ) ) {
                $def['disable-if'] = [ 'OR', $def['disable-if'],
                    [ '!==', $pref . static::GLOBAL_EXCEPTION_SUFFIX, '1' ]
                ];
            } else {
                $def['disable-if'] = [ '!==', $pref . static::GLOBAL_EXCEPTION_SUFFIX, '1' ];
            }
            // If this has a local exception, override it and append a help message to say so.
            if ( $isGlobal && $userOptionsLookup->getBoolOption( $user, $pref . static::LOCAL_EXCEPTION_SUFFIX ) ) {
                $def['default'] = $this->getOptionFromUser( $pref, $def, $globalPrefs );
                $help = '';
                if ( isset( $def['help-message'] ) ) {
                    $help .= $context->msg( $def['help-message'] )->parse() . '<br />';
                } elseif ( isset( $def['help'] ) ) {
                    $help .= $def['help'] . '<br />';
                }
                // Create a link to the relevant section of GlobalPreferences.
                $secFragment = static::getSectionFragmentId( $def['section'] );
                // Merge the help texts.
                $helpMsg = $context->msg( 'globalprefs-has-local-exception', [ $secFragment ] );
                unset( $def['help-message'] );
                $def['help'] = $help . $helpMsg->parse();
            }
            $allPrefs[$pref] = $def;
        }
        return $allPrefs;
    }
    /**
     * @inheritDoc
     */
    protected function saveFormData( $formData, \PreferencesFormOOUI $form, array $formDescriptor ) {
        if ( !$this->onGlobalPrefsPage( $form ) ) {
            return parent::saveFormData( $formData, $form, $formDescriptor );
        }
        '@phan-var GlobalPreferencesFormOOUI $form';
        $user = $form->getModifiedUser();
        // Difference from parent: removed 'editmyprivateinfo'
        if ( !$this->permissionManager->userHasRight( $user, 'editmyoptions' ) ) {
            return Status::newFatal( 'mypreferencesprotected' );
        }
        // Filter input
        $this->applyFilters( $formData, $formDescriptor, 'filterFromForm' );
        // In the parent, we remove 'realname', but this is unnecessary
        // here because GlobalPreferences removes this elsewhere, so
        // the field will not even appear in this form
        // Difference from parent: We are not collecting old user settings
        foreach ( $this->getSaveBlacklist() as $b ) {
            unset( $formData[$b] );
        }
        # If users have saved a value for a preference which has subsequently been disabled
        # via $wgHiddenPrefs, we don't want to destroy that setting in case the preference
        # is subsequently re-enabled
        $hiddenPrefs = $this->options->get( 'HiddenPrefs' );
        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
        foreach ( $hiddenPrefs as $pref ) {
            # If the user has not set a non-default value here, the default will be returned
            # and subsequently discarded
            $formData[$pref] = $userOptionsLookup->getOption( $user, $pref, null, true );
        }
        // Difference from parent: We are ignoring RClimit preference; the parent
        // checks for changes in that preference to update a hidden one, but the
        // RCFilters product is okay with having that be localized
        // We are also not resetting unused preferences in the global context.
        // Otherwise, users could lose data by editing their global preferences
        // on a wiki where some of the preferences don't exist. However, this means
        // that preferences for undeployed extensions or removed code are never
        // removed from the database...
        // Setting the actual preference values:
        $prefs = [];
        $suffixLen = strlen( self::GLOBAL_EXCEPTION_SUFFIX );
        foreach ( $formData as $name => $value ) {
            // If this is the '-global' counterpart to a preference.
            if ( self::isGlobalPrefName( $name ) && $value === true ) {
                // Determine the real name of the preference.
                $realName = substr( $name, 0, -$suffixLen );
                if ( array_key_exists( $realName, $formData ) ) {
                    $prefs[$realName] = $formData[$realName];
                    if ( $prefs[$realName] === null ) {
                        // Special case: null means don't save this row, which can keep the previous value
                        $prefs[$realName] = '';
                    }
                }
            }
        }
        $matricesToClear = [];
        // Now special processing for CheckMatrices
        foreach ( $this->findCheckMatrices( $formDescriptor ) as $name ) {
            $globalName = $name . self::GLOBAL_EXCEPTION_SUFFIX;
            // Find all separate controls for this CheckMatrix
            $checkMatrix = preg_grep( '/^' . preg_quote( $name ) . '/', array_keys( $formData ) );
            if ( array_key_exists( $globalName, $formData ) && $formData[$globalName] ) {
                // Setting is global, copy the checkmatrices
                foreach ( $checkMatrix as $input ) {
                    $prefs[$input] = $formData[$input];
                }
                $prefs[$name] = true;
            } else {
                // Remove all the rows for this CheckMatrix
                foreach ( $checkMatrix as $input ) {
                    unset( $prefs[$input] );
                }
                $matricesToClear[] = $name;
            }
            unset( $prefs[$globalName] );
        }
        $this->setGlobalPreferences( $user, $prefs, $form->getContext(), $matricesToClear );
        return true;
    }
    /**
     * Finds CheckMatrix inputs in a form descriptor
     *
     * @param array $formDescriptor
     * @return string[] Names of CheckMatrix options (parent only, not sub-checkboxes)
     */
    private function findCheckMatrices( array $formDescriptor ) {
        $result = [];
        foreach ( $formDescriptor as $name => $info ) {
            if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
                 ( isset( $info['class'] ) && $info['class'] == \HTMLCheckMatrix::class )
            ) {
                $result[] = $name;
            }
        }
        return $result;
    }
    /**
     * Get the HTML fragment identifier for a given preferences section. This is the leading part
     * of the provided section name, up to a slash (if there is one).
     * @param string $section A section name, as used in a preference definition.
     * @return string
     */
    public static function getSectionFragmentId( $section ) {
        $sectionId = preg_replace( '#/.*$#', '', $section );
        return 'mw-prefsection-' . $sectionId;
    }
    /**
     * Checks whether the given preference is globalizable.
     *
     * @param string $name Preference name
     * @param mixed[] &$info Preference description, by reference to avoid unnecessary cloning
     * @return bool
     */
    protected function isGlobalizablePreference( $name, &$info ) {
        // Preferences can opt out of being globalized by setting the 'noglobal' flag.
        if ( isset( $info['noglobal'] ) && $info['noglobal'] === true ) {
            return false;
        }
        // Ignore "is global" checkboxes
        if ( static::isGlobalPrefName( $name ) ) {
            return false;
        }
        // If a setting can't be changed, don't bother globalizing it
        if ( isset( $info['disabled'] ) && $info['disabled'] ) {
            return false;
        }
        $isAllowedType = isset( $info['type'] )
            && !in_array( $info['type'], $this->typesPrevented )
            && !in_array( $name, $this->disallowedPreferences );
        $isAllowedClass = isset( $info['class'] )
            && in_array( $info['class'], $this->allowedClasses );
        return $isAllowedType || $isAllowedClass;
    }
    /**
     * A convenience function to check if a preference name is for a global one.
     * @param string $name The name to check.
     * @return bool
     */
    public static function isGlobalPrefName( $name ) {
        return static::strEndsWith( $name, static::GLOBAL_EXCEPTION_SUFFIX );
    }
    /**
     * A convenience function to check if a preference name is for a local-exception preference.
     * @param string $name The name to check.
     * @return bool
     */
    public static function isLocalPrefName( $name ) {
        return static::strEndsWith( $name, static::LOCAL_EXCEPTION_SUFFIX );
    }
    /**
     * A convenience function to check a string to see if it ends in a given suffix.
     * @todo Replace with str_ends_with() when PHP 7 support was dropped.
     * @param string $name The name to check.
     * @param string $suffix The suffix to check for.
     * @return bool
     */
    protected static function strEndsWith( $name, $suffix ) {
        $nameSuffix = substr( $name, -strlen( $suffix ) );
        return ( $nameSuffix === $suffix );
    }
    /**
     * Checks if the user is globalized.
     * @param UserIdentity $user
     * @return bool
     */
    public function isUserGlobalized( UserIdentity $user ) {
        return $user->isRegistered() && $this->getUserID( $user ) !== 0;
    }
    /**
     * Gets the user's ID that we're using in the table
     * Returns 0 if the user is not global
     * @param UserIdentity $user
     * @return int
     */
    public function getUserID( UserIdentity $user ) {
        $id = $user->getId();
        $cache = $this->getCache();
        return $cache->getWithSetCallback( (string)$id, static function () use ( $user ) {
            $lookup = MediaWikiServices::getInstance()->getCentralIdLookupFactory()->getLookup();
            return $lookup->centralIdFromLocalUser( $user, CentralIdLookup::AUDIENCE_RAW );
        } );
    }
    /**
     * Get the user's global preferences.
     * @param UserIdentity $user
     * @param bool $skipCache Whether the preferences should be loaded strictly from DB
     * @return false|string[] Array keyed by preference name, or false if not found.
     */
    public function getGlobalPreferencesValues( UserIdentity $user, $skipCache = false ) {
        $id = $this->getUserID( $user );
        if ( !$id ) {
            $this->logger->warning( "Couldn't find a global ID for user {user}",
                [ 'user' => $user->getName() ]
            );
            return false;
        }
        $storage = $this->makeStorage( $user );
        return $storage->load( $skipCache );
    }
    /**
     * Save the user's global preferences.
     * @param User $user
     * @param array $newGlobalPrefs Array keyed by preference name.
     * @param IContextSource $context The request context.
     * @param string[] $checkMatricesToClear List of check matrix controls that
     *        need their rows purged
     * @return bool True on success, false if the user isn't global.
     */
    public function setGlobalPreferences(
        User $user,
        $newGlobalPrefs,
        IContextSource $context,
        array $checkMatricesToClear = []
    ) {
        $id = $this->getUserID( $user );
        if ( !$id ) {
            return false;
        }
        // Use a new instance of the current user to fetch the form descriptor because that way
        // we're working with the previous user options and not those that are currently in the
        // process of being saved (we only want the option names here, so don't care what the
        // values are).
        $userForDescriptor = User::newFromId( $user->getId() );
        // Save the global options.
        $storage = $this->makeStorage( $user );
        $knownPrefs = array_keys( $this->getFormDescriptor( $userForDescriptor, $context ) );
        $storage->save( $newGlobalPrefs, $knownPrefs, $checkMatricesToClear );
        $user->clearInstanceCache();
        return true;
    }
    /**
     * Deletes all of a user's global preferences.
     * Assumes that the user is globalized.
     * @param User $user
     */
    public function resetGlobalUserSettings( User $user ) {
        $this->makeStorage( $user )->delete();
    }
    /**
     * Convenience function to check if we're on the global prefs page.
     * @param IContextSource|null $context The context to use; if not set main request context is used.
     * @return bool
     */
    public function onGlobalPrefsPage( $context = null ) {
        $context = $context ?: RequestContext::getMain();
        return $context->getTitle() && $context->getTitle()->isSpecial( 'GlobalPreferences' );
    }
    /**
     * Convenience function to check if we're on the local prefs page.
     *
     * @param IContextSource|null $context The context to use; if not set main request context is used.
     * @return bool
     */
    public function onLocalPrefsPage( $context = null ) {
        $context = $context ?: RequestContext::getMain();
        return $context->getTitle() && $context->getTitle()->isSpecial( 'Preferences' );
    }
    /**
     * Processes local user options before they're saved
     *
     * @param UserIdentity $user
     * @param array &$modifiedOptions
     * @param array $originalOptions
     * @throws Exception
     */
    public function handleLocalPreferencesChange(
        UserIdentity $user,
        array &$modifiedOptions,
        array $originalOptions
    ) {
        // nothing to do if autoGlobals is empty
        if ( !$this->autoGlobals ) {
            return;
        }
        $preferencesChanged = false;
        $globals = $this->getGlobalPreferencesValues( $user, true );
        if ( $globals ) {
            // Need this so we can check for newly added options as well as soon-to-be-deleted options
            $mergedOptions = array_merge( $originalOptions, $modifiedOptions );
            foreach ( $mergedOptions as $optName => $optVal ) {
                // Ignore if ends in "-global".
                if ( static::isGlobalPrefName( $optName ) ) {
                    unset( $modifiedOptions[ $optName ] );
                }
                $isAutoGlobal = in_array( $optName, $this->autoGlobals );
                $localExceptionName = $optName . static::LOCAL_EXCEPTION_SUFFIX;
                $hasLocalException = isset( $mergedOptions[ $localExceptionName ] )
                    && $mergedOptions[ $localExceptionName ];
                if ( $isAutoGlobal
                    && !$hasLocalException
                    && array_key_exists( $optName, $globals )
                ) {
                    $globals[$optName] = $optVal;
                    $preferencesChanged = true;
                }
            }
            if ( $preferencesChanged ) {
                $this->makeStorage( $user )->save( $globals, array_keys( $globals ) );
            }
        }
    }
    /**
     * Factory for preference storage
     *
     * @param UserIdentity $user
     * @return Storage
     */
    protected function makeStorage( UserIdentity $user ) {
        $id = $this->getUserID( $user );
        if ( !$id ) {
            throw new LogicException( 'User not set or is not global on call to ' . __METHOD__ );
        }
        return new Storage( $id );
    }
}