Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.71% covered (warning)
71.71%
180 / 251
40.00% covered (danger)
40.00%
8 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
GlobalPreferencesFactory
71.71% covered (warning)
71.71%
180 / 251
40.00% covered (danger)
40.00%
8 / 20
263.27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 setAutoGlobals
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFormDescriptor
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
6.60
 getPreferencesLocal
93.75% covered (success)
93.75%
45 / 48
0.00% covered (danger)
0.00%
0 / 1
6.01
 getPreferencesGlobal
40.00% covered (danger)
40.00%
22 / 55
0.00% covered (danger)
0.00%
0 / 1
37.14
 saveFormData
88.57% covered (warning)
88.57%
31 / 35
0.00% covered (danger)
0.00%
0 / 1
15.34
 findCheckMatrices
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
72
 getSectionFragmentId
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isGlobalizablePreference
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
11
 isGlobalPrefName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isLocalPrefName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isUserGlobalized
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getUserID
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getGlobalPreferencesValues
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
2.50
 setGlobalPreferences
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.00
 resetGlobalUserSettings
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 onGlobalPrefsPage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 onLocalPrefsPage
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 handleLocalPreferencesChange
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
11
 makeStorage
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2/**
3 * Implements global preferences for MediaWiki
4 *
5 * @author Kunal Mehta <legoktm@gmail.com>
6 * @license http://www.gnu.org/copyleft/gpl.html GPL-2.0-or-later
7 * @file
8 * @ingroup Extensions
9 *
10 * Partially based off of work by Werdna
11 * https://www.mediawiki.org/wiki/Special:Code/MediaWiki/49790
12 */
13
14namespace GlobalPreferences;
15
16use GlobalPreferences\Services\GlobalPreferencesHookRunner;
17use LogicException;
18use MediaWiki\Auth\AuthManager;
19use MediaWiki\Config\ServiceOptions;
20use MediaWiki\Context\IContextSource;
21use MediaWiki\Context\RequestContext;
22use MediaWiki\HookContainer\HookContainer;
23use MediaWiki\Html\Html;
24use MediaWiki\HTMLForm\Field\HTMLCheckMatrix;
25use MediaWiki\HTMLForm\Field\HTMLSelectOrOtherField;
26use MediaWiki\Language\ILanguageConverter;
27use MediaWiki\Language\Language;
28use MediaWiki\Language\RawMessage;
29use MediaWiki\Languages\LanguageNameUtils;
30use MediaWiki\Linker\LinkRenderer;
31use MediaWiki\MediaWikiServices;
32use MediaWiki\Permissions\PermissionManager;
33use MediaWiki\Preferences\DefaultPreferencesFactory;
34use MediaWiki\SpecialPage\SpecialPage;
35use MediaWiki\Status\Status;
36use MediaWiki\Title\NamespaceInfo;
37use MediaWiki\User\CentralId\CentralIdLookup;
38use MediaWiki\User\Options\UserOptionsLookup;
39use MediaWiki\User\User;
40use MediaWiki\User\UserIdentity;
41use OOUI\ButtonWidget;
42use RuntimeException;
43
44/**
45 * Global preferences.
46 * @package GlobalPreferences
47 */
48class GlobalPreferencesFactory extends DefaultPreferencesFactory {
49
50    /**
51     * The suffix appended to preference names
52     * for the associated preference that tracks whether they have a local override.
53     */
54    public const LOCAL_EXCEPTION_SUFFIX = '-local-exception';
55
56    /**
57     * The suffix appended to preference names for their global counterparts.
58     */
59    public const GLOBAL_EXCEPTION_SUFFIX = '-global';
60
61    /**
62     * @var string[] Names of autoglobal options
63     */
64    protected $autoGlobals = [];
65
66    /**
67     * "bad" preferences that we should remove from
68     * Special:GlobalPrefs
69     * @var array
70     */
71    protected $disallowedPreferences = [
72        // Stored in user table, doesn't work yet
73        'realname',
74        // @todo Show CA user id / shared user table id?
75        'userid',
76        // @todo Show CA global groups instead?
77        'usergroups',
78        // @todo Should global edit count instead?
79        'editcount',
80        'registrationdate',
81        // Signature could be global, but links in it are too likely to break.
82        'nickname',
83        'fancysig',
84    ];
85
86    /**
87     * Preference types that we should not add a checkbox for
88     * @var array
89     */
90    protected $typesPrevented = [
91        'info',
92        'hidden',
93        'api',
94    ];
95
96    /**
97     * Preference classes that are allowed to be global
98     * @var array
99     */
100    protected $allowedClasses = [
101        // Checking old alias for compatibility with unchanged extensions
102        \HTMLSelectOrOtherField::class,
103        HTMLSelectOrOtherField::class,
104        \MediaWiki\Extension\BetaFeatures\HTMLFeatureField::class,
105        // Checking old alias for compatibility with unchanged extensions
106        \HTMLCheckMatrix::class,
107        HTMLCheckMatrix::class,
108    ];
109
110    private GlobalPreferencesHookRunner $globalPreferencesHookRunner;
111
112    public function __construct(
113        ServiceOptions $options,
114        Language $contLang,
115        AuthManager $authManager,
116        LinkRenderer $linkRenderer,
117        NamespaceInfo $nsInfo,
118        PermissionManager $permissionManager,
119        ILanguageConverter $languageConverter,
120        LanguageNameUtils $languageNameUtils,
121        HookContainer $hookContainer,
122        UserOptionsLookup $userOptionsLookup,
123        GlobalPreferencesHookRunner $globalPreferencesHookRunner
124    ) {
125        parent::__construct(
126            $options,
127            $contLang,
128            $authManager,
129            $linkRenderer,
130            $nsInfo,
131            $permissionManager,
132            $languageConverter,
133            $languageNameUtils,
134            $hookContainer,
135            $userOptionsLookup
136        );
137
138        $this->globalPreferencesHookRunner = $globalPreferencesHookRunner;
139    }
140
141    /**
142     * Sets the list of options for which setting the local value should transparently update
143     * the global value.
144     *
145     * @param string[] $list
146     */
147    public function setAutoGlobals( array $list ) {
148        $this->autoGlobals = $list;
149    }
150
151    /**
152     * Get all user preferences.
153     * @param User $user
154     * @param IContextSource $context The current request context
155     * @return array|null
156     */
157    public function getFormDescriptor( User $user, IContextSource $context ) {
158        $globalPrefs = $this->getGlobalPreferencesValues( $user, Storage::SKIP_CACHE );
159        // The above function can return false
160        $globalPrefNames = $globalPrefs ? array_keys( $globalPrefs ) : [];
161        $preferences = parent::getFormDescriptor( $user, $context );
162        if ( $this->onGlobalPrefsPage( $context ) ) {
163            if ( $globalPrefs === false ) {
164                throw new RuntimeException(
165                    "Attempted to load global preferences page for {$user->getName()} whose "
166                    . 'preference values failed to load'
167                );
168            }
169            return $this->getPreferencesGlobal( $user, $preferences, $globalPrefs, $context );
170        }
171        return $this->getPreferencesLocal( $user, $preferences, $globalPrefNames, $context );
172    }
173
174    /**
175     * Add help-text to the local preferences where they're globalized,
176     * and add the link to Special:GlobalPreferences to the personal preferences tab.
177     * @param User $user
178     * @param mixed[][] $preferences The preferences array.
179     * @param string[] $globalPrefNames The names of those preferences that are already global.
180     * @param IContextSource $context The current request context
181     * @return mixed[][]
182     */
183    protected function getPreferencesLocal(
184        User $user,
185        array $preferences,
186        array $globalPrefNames,
187        IContextSource $context
188    ) {
189        $this->logger->debug( "Creating local preferences array for '{$user->getName()}'" );
190        $modifiedPrefs = [];
191        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
192        foreach ( $preferences as $name => $def ) {
193            $modifiedPrefs[$name] = $def;
194            // If this is not globalizable, or hasn't been set globally.
195            if ( !isset( $def['section'] )
196                || !in_array( $name, $globalPrefNames )
197                || !$this->isGlobalizablePreference( $name, $def )
198            ) {
199                continue;
200            }
201            $localExName = $name . UserOptionsLookup::LOCAL_EXCEPTION_SUFFIX;
202            $localExValueUser = $userOptionsLookup->getBoolOption( $user, $localExName );
203
204            // Add a new local exception preference after this one.
205            $cssClasses = [
206                'mw-globalprefs-local-exception',
207                'mw-globalprefs-local-exception-for-' . $name,
208                'mw-prefs-search-noindex',
209            ];
210            $section = $def['section'];
211            $secFragment = static::getSectionFragmentId( $section );
212            $labelMsg = $context->msg( 'globalprefs-set-local-exception', [ $secFragment ] );
213            $modifiedPrefs[$localExName] = [
214                'type' => 'toggle',
215                'label-raw' => $labelMsg->parse(),
216                'default' => $localExValueUser,
217                'section' => $section,
218                'cssclass' => implode( ' ', $cssClasses ),
219                'hide-if' => $def['hide-if'] ?? false,
220                'disable-if' => $def['disable-if'] ?? false,
221            ];
222            if ( isset( $def['disable-if'] ) ) {
223                $modifiedPrefs[$name]['disable-if'] = [ 'OR', $def['disable-if'],
224                    [ '!==', $localExName, '1' ]
225                ];
226            } else {
227                $modifiedPrefs[$name]['disable-if'] = [ '!==', $localExName, '1' ];
228            }
229        }
230        $preferences = $modifiedPrefs;
231
232        // Add a link to GlobalPreferences to the local preferences form.
233        $linkObject = new ButtonWidget( [
234            'href' => SpecialPage::getTitleFor( 'GlobalPreferences' )->getLinkURL(),
235            'label' => $context->msg( 'globalprefs-info-link' )->text(),
236        ] );
237        $link = $linkObject->toString();
238
239        $preferences['global-info'] = [
240            'type' => 'info',
241            'section' => 'personal/info',
242            'label-message' => 'globalprefs-info-label',
243            'raw' => true,
244            'default' => $link,
245            'help-message' => 'globalprefs-info-help',
246        ];
247
248        return $preferences;
249    }
250
251    /**
252     * Add the '-global' counterparts to all preferences, and override the local exception.
253     * @param User $user
254     * @param mixed[][] $preferences The preferences array.
255     * @param mixed[] $globalPrefs The array of global preferences.
256     * @param IContextSource $context The current request context
257     * @return mixed[][]
258     */
259    protected function getPreferencesGlobal(
260        User $user,
261        array $preferences,
262        array $globalPrefs,
263        IContextSource $context
264    ) {
265        $allPrefs = [];
266        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
267
268        // Add the "Restore all default preferences" link like on Special:Preferences
269        // (the normal preference entry is not globalizable)
270        $allPrefs['restoreprefs'] = [
271            'type' => 'info',
272            'raw' => true,
273            'label-message' => 'prefs-user-restoreprefs-label',
274            'default' => Html::element(
275                'a',
276                [
277                    'href' => SpecialPage::getTitleFor( 'GlobalPreferences' )
278                        ->getSubpage( 'reset' )->getLocalURL()
279                ],
280                $context->msg( 'globalprefs-restoreprefs' )->text()
281            ),
282            'section' => 'personal/info',
283        ];
284
285        // Add all corresponding new global fields.
286        foreach ( $preferences as $pref => $def ) {
287            // Ignore unwanted preferences.
288            if ( !$this->isGlobalizablePreference( $pref, $def ) ) {
289                continue;
290            }
291            // If a 'info' preference was allowed (i.e. 'canglobal' is set to true), then we should not add a checkbox
292            // as it doesn't make sense.
293            if ( isset( $def['type'] ) && $def['type'] === 'info' ) {
294                $allPrefs[$pref] = $def;
295                continue;
296            }
297            // Create the new preference.
298            $isGlobal = isset( $globalPrefs[$pref] );
299            $allPrefs[$pref . static::GLOBAL_EXCEPTION_SUFFIX] = [
300                'type' => 'toggle',
301                // Make the tooltip and the label the same, because the label is normally hidden.
302                'tooltip' => 'globalprefs-check-label',
303                'label-message' => 'tooltip-globalprefs-check-label',
304                'default' => $isGlobal,
305                'section' => $def['section'],
306                'cssclass' => 'mw-globalprefs-global-check mw-globalprefs-checkbox-for-' . $pref,
307                'hide-if' => $def['hide-if'] ?? false,
308                'disable-if' => $def['disable-if'] ?? false,
309            ];
310            if ( isset( $def['disable-if'] ) ) {
311                $def['disable-if'] = [ 'OR', $def['disable-if'],
312                    [ '!==', $pref . static::GLOBAL_EXCEPTION_SUFFIX, '1' ]
313                ];
314            } else {
315                $def['disable-if'] = [ '!==', $pref . static::GLOBAL_EXCEPTION_SUFFIX, '1' ];
316            }
317            // If this has a local exception, override it and append a help message to say so.
318            if ( $isGlobal
319                && $userOptionsLookup->getBoolOption( $user, $pref . UserOptionsLookup::LOCAL_EXCEPTION_SUFFIX )
320            ) {
321                $def['default'] = $this->getOptionFromUser( $pref, $def, $globalPrefs );
322                // Create a link to the relevant section of GlobalPreferences.
323                $secFragment = static::getSectionFragmentId( $def['section'] );
324                $helpMsg = [ 'globalprefs-has-local-exception', $secFragment ];
325                // Merge the help messages.
326                if ( isset( $def['help'] ) ) {
327                    $def['help-messages'] = [ new RawMessage( $def['help'] . '<br />' ), $helpMsg ];
328                    unset( $def['help'] );
329                } elseif ( isset( $def['help-message'] ) ) {
330                    $def['help-messages'] = [ $def['help-message'], new RawMessage( '<br />' ), $helpMsg ];
331                    unset( $def['help-message'] );
332                } elseif ( isset( $def['help-messages'] ) ) {
333                    $def['help-messages'][] = new RawMessage( '<br />' );
334                    $def['help-messages'][] = $helpMsg;
335                } else {
336                    $def['help-message'] = $helpMsg;
337                }
338            }
339
340            $allPrefs[$pref] = $def;
341        }
342
343        return $allPrefs;
344    }
345
346    /**
347     * @inheritDoc
348     */
349    protected function saveFormData( $formData, \PreferencesFormOOUI $form, array $formDescriptor ) {
350        if ( !$this->onGlobalPrefsPage( $form ) ) {
351            return parent::saveFormData( $formData, $form, $formDescriptor );
352        }
353        '@phan-var GlobalPreferencesFormOOUI $form';
354
355        $user = $form->getModifiedUser();
356
357        // Difference from parent: removed 'editmyprivateinfo'
358        if ( !$this->permissionManager->userHasRight( $user, 'editmyoptions' ) ) {
359            return Status::newFatal( 'mypreferencesprotected' );
360        }
361
362        // Filter input
363        $this->applyFilters( $formData, $formDescriptor, 'filterFromForm' );
364
365        // In the parent, we remove 'realname', but this is unnecessary
366        // here because GlobalPreferences removes this elsewhere, so
367        // the field will not even appear in this form
368
369        // Difference from parent: We are not collecting old user settings
370
371        foreach ( $this->getSaveBlacklist() as $b ) {
372            unset( $formData[$b] );
373        }
374
375        # If users have saved a value for a preference which has subsequently been disabled
376        # via $wgHiddenPrefs, we don't want to destroy that setting in case the preference
377        # is subsequently re-enabled
378        $hiddenPrefs = $this->options->get( 'HiddenPrefs' );
379        $userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
380        foreach ( $hiddenPrefs as $pref ) {
381            # If the user has not set a non-default value here, the default will be returned
382            # and subsequently discarded
383            $formData[$pref] = $userOptionsLookup->getOption( $user, $pref, null, true );
384        }
385
386        // Difference from parent: We are ignoring RClimit preference; the parent
387        // checks for changes in that preference to update a hidden one, but the
388        // RCFilters product is okay with having that be localized
389
390        // We are also not resetting unused preferences in the global context.
391        // Otherwise, users could lose data by editing their global preferences
392        // on a wiki where some of the preferences don't exist. However, this means
393        // that preferences for undeployed extensions or removed code are never
394        // removed from the database...
395
396        // Setting the actual preference values:
397        $prefs = [];
398        $suffixLen = strlen( self::GLOBAL_EXCEPTION_SUFFIX );
399        foreach ( $formData as $name => $value ) {
400            // If this is the '-global' counterpart to a preference.
401            if ( self::isGlobalPrefName( $name ) && $value === true ) {
402                // Determine the real name of the preference.
403                $realName = substr( $name, 0, -$suffixLen );
404                if ( array_key_exists( $realName, $formData ) ) {
405                    $prefs[$realName] = $formData[$realName];
406                    if ( $prefs[$realName] === null ) {
407                        // Special case: null means don't save this row, which can keep the previous value
408                        $prefs[$realName] = '';
409                    }
410                }
411            }
412        }
413
414        $matricesToClear = [];
415        // Now special processing for CheckMatrices
416        foreach ( $this->findCheckMatrices( $formDescriptor ) as $name ) {
417            $globalName = $name . self::GLOBAL_EXCEPTION_SUFFIX;
418            // Find all separate controls for this CheckMatrix
419            $checkMatrix = preg_grep( '/^' . preg_quote( $name ) . '/', array_keys( $formData ) );
420            if ( array_key_exists( $globalName, $formData ) && $formData[$globalName] ) {
421                // Setting is global, copy the checkmatrices
422                foreach ( $checkMatrix as $input ) {
423                    $prefs[$input] = $formData[$input];
424                }
425                $prefs[$name] = true;
426            } else {
427                // Remove all the rows for this CheckMatrix
428                foreach ( $checkMatrix as $input ) {
429                    unset( $prefs[$input] );
430                }
431                $matricesToClear[] = $name;
432            }
433            unset( $prefs[$globalName] );
434        }
435        $this->setGlobalPreferences( $user, $prefs, $form->getContext(), $matricesToClear );
436
437        return true;
438    }
439
440    /**
441     * Finds CheckMatrix inputs in a form descriptor
442     *
443     * @param array $formDescriptor
444     * @return string[] Names of CheckMatrix options (parent only, not sub-checkboxes)
445     */
446    private function findCheckMatrices( array $formDescriptor ) {
447        $result = [];
448        foreach ( $formDescriptor as $name => $info ) {
449            if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
450                // Checking old alias for compatibility with unchanged extensions
451                ( isset( $info['class'] ) && $info['class'] == \HTMLCheckMatrix::class ) ||
452                ( isset( $info['class'] ) && $info['class'] == HTMLCheckMatrix::class )
453            ) {
454                $result[] = $name;
455            }
456        }
457
458        return $result;
459    }
460
461    /**
462     * Get the HTML fragment identifier for a given preferences section. This is the leading part
463     * of the provided section name, up to a slash (if there is one).
464     * @param string $section A section name, as used in a preference definition.
465     * @return string
466     */
467    public static function getSectionFragmentId( $section ) {
468        $sectionId = preg_replace( '#/.*$#', '', $section );
469        return 'mw-prefsection-' . $sectionId;
470    }
471
472    /**
473     * Checks whether the given preference is globalizable.
474     *
475     * @param string $name Preference name
476     * @param mixed[] &$info Preference description, by reference to avoid unnecessary cloning
477     * @return bool
478     */
479    protected function isGlobalizablePreference( $name, &$info ) {
480        // Preferences can opt out of being globalized by setting the 'noglobal' flag.
481        if ( isset( $info['noglobal'] ) && $info['noglobal'] === true ) {
482            return false;
483        }
484
485        // Ignore "is global" checkboxes
486        if ( static::isGlobalPrefName( $name ) ) {
487            return false;
488        }
489
490        // If a setting can't be changed, don't bother globalizing it
491        if ( isset( $info['disabled'] ) && $info['disabled'] ) {
492            return false;
493        }
494
495        // Allow explicitly define if a preference can be globalized,
496        // especially useful for custom field classes.
497        // TODO: Deprecate 'noglobal' in favour of this param.
498        if ( isset( $info['canglobal'] ) ) {
499            return (bool)$info['canglobal'];
500        }
501
502        $isAllowedType = isset( $info['type'] )
503            && !in_array( $info['type'], $this->typesPrevented )
504            && !in_array( $name, $this->disallowedPreferences );
505
506        $isAllowedClass = isset( $info['class'] )
507            && in_array( $info['class'], $this->allowedClasses );
508
509        return $isAllowedType || $isAllowedClass;
510    }
511
512    /**
513     * A convenience function to check if a preference name is for a global one.
514     * @param string $name The name to check.
515     * @return bool
516     */
517    public static function isGlobalPrefName( $name ) {
518        return str_ends_with( $name, static::GLOBAL_EXCEPTION_SUFFIX );
519    }
520
521    /**
522     * A convenience function to check if a preference name is for a local-exception preference.
523     * @param string $name The name to check.
524     * @return bool
525     */
526    public static function isLocalPrefName( $name ) {
527        return str_ends_with( $name, UserOptionsLookup::LOCAL_EXCEPTION_SUFFIX );
528    }
529
530    /**
531     * Checks if the user is globalized.
532     * @param UserIdentity $user
533     * @return bool
534     */
535    public function isUserGlobalized( UserIdentity $user ) {
536        $utils = MediaWikiServices::getInstance()->getUserIdentityUtils();
537        return $utils->isNamed( $user ) && $this->getUserID( $user ) !== 0;
538    }
539
540    /**
541     * Gets the user's ID that we're using in the table
542     * Returns 0 if the user is not global
543     * @param UserIdentity $user
544     * @return int
545     */
546    public function getUserID( UserIdentity $user ) {
547        $lookup = MediaWikiServices::getInstance()->getCentralIdLookup();
548        return $lookup->isOwned( $user ) ?
549            $lookup->centralIdFromName( $user->getName(), CentralIdLookup::AUDIENCE_RAW ) :
550            0;
551    }
552
553    /**
554     * Get the user's global preferences.
555     * @param UserIdentity $user
556     * @param bool $skipCache Whether the preferences should be loaded strictly from DB
557     * @return false|string[] Array keyed by preference name, or false if not found.
558     */
559    public function getGlobalPreferencesValues( UserIdentity $user, $skipCache = false ) {
560        $id = $this->getUserID( $user );
561        if ( !$id ) {
562            $this->logger->warning( "Couldn't find a global ID for user {user}",
563                [ 'user' => $user->getName() ]
564            );
565            return false;
566        }
567        $storage = $this->makeStorage( $user );
568        return $storage->load( $skipCache );
569    }
570
571    /**
572     * Save the user's global preferences.
573     * @param User $user
574     * @param array $newGlobalPrefs Array keyed by preference name.
575     * @param IContextSource $context The request context.
576     * @param string[] $checkMatricesToClear List of check matrix controls that
577     *        need their rows purged
578     * @return bool True on success, false if the user isn't global.
579     */
580    public function setGlobalPreferences(
581        User $user,
582        $newGlobalPrefs,
583        IContextSource $context,
584        array $checkMatricesToClear = []
585    ) {
586        $id = $this->getUserID( $user );
587        if ( !$id ) {
588            return false;
589        }
590
591        // Use a new instance of the current user to fetch the form descriptor because that way
592        // we're working with the previous user options and not those that are currently in the
593        // process of being saved (we only want the option names here, so don't care what the
594        // values are).
595        $userForDescriptor = MediaWikiServices::getInstance()->getUserFactory()->newFromId( $user->getId() );
596
597        // Save the global options.
598        $storage = $this->makeStorage( $user );
599        $knownPrefs = array_keys( $this->getFormDescriptor( $userForDescriptor, $context ) );
600
601        $oldPreferences = $this->getGlobalPreferencesValues( $user );
602        $storage->save( $newGlobalPrefs, $knownPrefs, $checkMatricesToClear );
603
604        $this->globalPreferencesHookRunner->onGlobalPreferencesSetGlobalPreferences(
605            $user,
606            $oldPreferences,
607            $newGlobalPrefs
608        );
609
610        $user->clearInstanceCache();
611        return true;
612    }
613
614    /**
615     * Deletes all of a user's global preferences.
616     * Assumes that the user is globalized.
617     * @param User $user
618     */
619    public function resetGlobalUserSettings( User $user ) {
620        $oldPreferences = $this->getGlobalPreferencesValues( $user );
621        $this->makeStorage( $user )->delete();
622        $this->globalPreferencesHookRunner->onGlobalPreferencesSetGlobalPreferences(
623            $user,
624            $oldPreferences,
625            []
626        );
627    }
628
629    /**
630     * Convenience function to check if we're on the global prefs page.
631     * @param IContextSource|null $context The context to use; if not set main request context is used.
632     * @return bool
633     */
634    public function onGlobalPrefsPage( $context = null ) {
635        $context = $context ?: RequestContext::getMain();
636        return $context->getTitle() && $context->getTitle()->isSpecial( 'GlobalPreferences' );
637    }
638
639    /**
640     * Convenience function to check if we're on the local prefs page.
641     *
642     * @param IContextSource|null $context The context to use; if not set main request context is used.
643     * @return bool
644     */
645    public function onLocalPrefsPage( $context = null ) {
646        $context = $context ?: RequestContext::getMain();
647        return $context->getTitle() && $context->getTitle()->isSpecial( 'Preferences' );
648    }
649
650    /**
651     * Processes local user options before they're saved
652     *
653     * @param UserIdentity $user
654     * @param array &$modifiedOptions
655     * @param array $originalOptions
656     */
657    public function handleLocalPreferencesChange(
658        UserIdentity $user,
659        array &$modifiedOptions,
660        array $originalOptions
661    ) {
662        $shouldModify = [];
663        $mergedOptions = array_merge( $originalOptions, $modifiedOptions );
664        foreach ( $this->autoGlobals as $optName ) {
665            // $modifiedOptions can contains options that not actually modified, filter out them
666            if ( array_key_exists( $optName, $modifiedOptions ) &&
667                ( !array_key_exists( $optName, $originalOptions ) ||
668                $modifiedOptions[$optName] !== $originalOptions[$optName] ) &&
669                // And skip options that have local exceptions
670                !( $mergedOptions[$optName . UserOptionsLookup::LOCAL_EXCEPTION_SUFFIX] ?? false )
671            ) {
672                $shouldModify[$optName] = $modifiedOptions[$optName];
673            }
674        }
675        // No auto-global options are modified
676        if ( !$shouldModify ) {
677            return;
678        }
679
680        $preferencesChanged = false;
681        $globals = $this->getGlobalPreferencesValues( $user, true );
682
683        if ( $globals ) {
684            foreach ( $shouldModify as $optName => $optVal ) {
685                if ( array_key_exists( $optName, $globals ) ) {
686                    $globals[$optName] = $optVal;
687                    $preferencesChanged = true;
688                }
689            }
690
691            if ( $preferencesChanged ) {
692                $this->makeStorage( $user )->save( $globals, array_keys( $globals ) );
693            }
694        }
695    }
696
697    /**
698     * Factory for preference storage
699     *
700     * @param UserIdentity $user
701     * @return Storage
702     */
703    protected function makeStorage( UserIdentity $user ) {
704        $id = $this->getUserID( $user );
705        if ( !$id ) {
706            throw new LogicException( 'User not set or is not global on call to ' . __METHOD__ );
707        }
708        return new Storage( $id );
709    }
710}