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