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