Page MenuHomeWickedGov Phorge

GlobalPreferencesFactory.php
No OneTemporary

Size
23 KB
Referenced Files
None
Subscribers
None

GlobalPreferencesFactory.php

<?php
/**
* Implements global preferences for MediaWiki
*
* @author Kunal Mehta <legoktm@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GPL-2.0-or-later
* @file
* @ingroup Extensions
*
* Partially based off of work by Werdna
* https://www.mediawiki.org/wiki/Special:Code/MediaWiki/49790
*/
namespace GlobalPreferences;
use GlobalPreferences\Services\GlobalPreferencesHookRunner;
use LogicException;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\Html\Html;
use MediaWiki\HTMLForm\Field\HTMLCheckMatrix;
use MediaWiki\HTMLForm\Field\HTMLSelectOrOtherField;
use MediaWiki\Language\ILanguageConverter;
use MediaWiki\Language\Language;
use MediaWiki\Language\RawMessage;
use MediaWiki\Languages\LanguageNameUtils;
use MediaWiki\Linker\LinkRenderer;
use MediaWiki\MediaWikiServices;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Preferences\DefaultPreferencesFactory;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\Status\Status;
use MediaWiki\Title\NamespaceInfo;
use MediaWiki\User\CentralId\CentralIdLookup;
use MediaWiki\User\Options\UserOptionsLookup;
use MediaWiki\User\User;
use MediaWiki\User\UserIdentity;
use OOUI\ButtonWidget;
use RuntimeException;
use WeakReference;
/**
* Global preferences.
* @package GlobalPreferences
*/
class GlobalPreferencesFactory extends DefaultPreferencesFactory {
/**
* The suffix appended to preference names
* for the associated preference that tracks whether they have a local override.
*/
public const LOCAL_EXCEPTION_SUFFIX = '-local-exception';
/**
* The suffix appended to preference names for their global counterparts.
*/
public const GLOBAL_EXCEPTION_SUFFIX = '-global';
/**
* @var string[] Names of autoglobal options
*/
protected $autoGlobals = [];
/**
* "bad" preferences that we should remove from
* Special:GlobalPrefs
* @var array
*/
protected $disallowedPreferences = [
// Stored in user table, doesn't work yet
'realname',
// @todo Show CA user id / shared user table id?
'userid',
// @todo Show CA global groups instead?
'usergroups',
// @todo Should global edit count instead?
'editcount',
'registrationdate',
// Signature could be global, but links in it are too likely to break.
'nickname',
'fancysig',
];
/**
* Preference types that we should not add a checkbox for
* @var array
*/
protected $typesPrevented = [
'info',
'hidden',
'api',
];
/**
* Preference classes that are allowed to be global
* @var array
*/
protected $allowedClasses = [
// Checking old alias for compatibility with unchanged extensions
\HTMLSelectOrOtherField::class,
HTMLSelectOrOtherField::class,
\MediaWiki\Extension\BetaFeatures\HTMLFeatureField::class,
// Checking old alias for compatibility with unchanged extensions
\HTMLCheckMatrix::class,
HTMLCheckMatrix::class,
];
/**
* Weak reference to the UserIdentity with global ID $this->globalUserId
* @var WeakReference|null
*/
private $globalUserRef;
/** @var int|null */
private $globalUserId;
private readonly GlobalPreferencesHookRunner $globalPreferencesHookRunner;
public function __construct(
ServiceOptions $options,
Language $contLang,
AuthManager $authManager,
LinkRenderer $linkRenderer,
NamespaceInfo $nsInfo,
PermissionManager $permissionManager,
ILanguageConverter $languageConverter,
LanguageNameUtils $languageNameUtils,
HookContainer $hookContainer,
UserOptionsLookup $userOptionsLookup,
) {
parent::__construct(
$options,
$contLang,
$authManager,
$linkRenderer,
$nsInfo,
$permissionManager,
$languageConverter,
$languageNameUtils,
$hookContainer,
$userOptionsLookup
);
$this->globalPreferencesHookRunner = new GlobalPreferencesHookRunner( $hookContainer );
}
/**
* Sets the list of options for which setting the local value should transparently update
* the global value.
*
* @param string[] $list
*/
public function setAutoGlobals( array $list ) {
$this->autoGlobals = $list;
}
/**
* Get all user preferences.
* @param User $user
* @param IContextSource $context The current request context
* @return array|null
*/
public function getFormDescriptor( User $user, IContextSource $context ) {
$globalPrefs = $this->getGlobalPreferencesValues( $user, Storage::SKIP_CACHE );
// The above function can return false
$globalPrefNames = $globalPrefs ? array_keys( $globalPrefs ) : [];
$preferences = parent::getFormDescriptor( $user, $context );
if ( $this->onGlobalPrefsPage( $context ) ) {
if ( $globalPrefs === false ) {
throw new RuntimeException(
"Attempted to load global preferences page for {$user->getName()} whose "
. 'preference values failed to load'
);
}
return $this->getPreferencesGlobal( $user, $preferences, $globalPrefs, $context );
}
return $this->getPreferencesLocal( $user, $preferences, $globalPrefNames, $context );
}
/**
* Add help-text to the local preferences where they're globalized,
* and add the link to Special:GlobalPreferences to the personal preferences tab.
* @param User $user
* @param mixed[][] $preferences The preferences array.
* @param string[] $globalPrefNames The names of those preferences that are already global.
* @param IContextSource $context The current request context
* @return mixed[][]
*/
protected function getPreferencesLocal(
User $user,
array $preferences,
array $globalPrefNames,
IContextSource $context
) {
$this->logger->debug( "Creating local preferences array for '{$user->getName()}'" );
$modifiedPrefs = [];
$userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
foreach ( $preferences as $name => $def ) {
$modifiedPrefs[$name] = $def;
// If this is not globalizable, or hasn't been set globally.
if ( !isset( $def['section'] )
|| !in_array( $name, $globalPrefNames )
|| !$this->isGlobalizablePreference( $name, $def )
) {
continue;
}
$localExName = $name . UserOptionsLookup::LOCAL_EXCEPTION_SUFFIX;
$localExValueUser = $userOptionsLookup->getBoolOption( $user, $localExName );
// Add a new local exception preference after this one.
$cssClasses = [
'mw-globalprefs-local-exception',
'mw-globalprefs-local-exception-for-' . $name,
'mw-prefs-search-noindex',
];
$section = $def['section'];
$secFragment = static::getSectionFragmentId( $section );
$labelMsg = $context->msg( 'globalprefs-set-local-exception', [ $secFragment ] );
$modifiedPrefs[$localExName] = [
'type' => 'toggle',
'label-raw' => $labelMsg->parse(),
'default' => $localExValueUser,
'section' => $section,
'cssclass' => implode( ' ', $cssClasses ),
'hide-if' => $def['hide-if'] ?? false,
'disable-if' => $def['disable-if'] ?? false,
];
if ( isset( $def['disable-if'] ) ) {
$modifiedPrefs[$name]['disable-if'] = [ 'OR', $def['disable-if'],
[ '!==', $localExName, '1' ]
];
} else {
$modifiedPrefs[$name]['disable-if'] = [ '!==', $localExName, '1' ];
}
}
$preferences = $modifiedPrefs;
// Add a link to GlobalPreferences to the local preferences form.
$linkObject = new ButtonWidget( [
'href' => SpecialPage::getTitleFor( 'GlobalPreferences' )->getLinkURL(),
'label' => $context->msg( 'globalprefs-info-link' )->text(),
] );
$link = $linkObject->toString();
$preferences['global-info'] = [
'type' => 'info',
'section' => 'personal/info',
'label-message' => 'globalprefs-info-label',
'raw' => true,
'default' => $link,
'help-message' => 'globalprefs-info-help',
];
return $preferences;
}
/**
* Add the '-global' counterparts to all preferences, and override the local exception.
* @param User $user
* @param mixed[][] $preferences The preferences array.
* @param mixed[] $globalPrefs The array of global preferences.
* @param IContextSource $context The current request context
* @return mixed[][]
*/
protected function getPreferencesGlobal(
User $user,
array $preferences,
array $globalPrefs,
IContextSource $context
) {
$allPrefs = [];
$userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
// Add the "Restore all default preferences" link like on Special:Preferences
// (the normal preference entry is not globalizable)
$allPrefs['restoreprefs'] = [
'type' => 'info',
'raw' => true,
'label-message' => 'prefs-user-restoreprefs-label',
'default' => Html::element(
'a',
[
'href' => SpecialPage::getTitleFor( 'GlobalPreferences' )
->getSubpage( 'reset' )->getLocalURL()
],
$context->msg( 'globalprefs-restoreprefs' )->text()
),
'section' => 'personal/info',
];
// Add all corresponding new global fields.
foreach ( $preferences as $pref => $def ) {
// Ignore unwanted preferences.
if ( !$this->isGlobalizablePreference( $pref, $def ) ) {
continue;
}
// If a 'info' preference was allowed (i.e. 'canglobal' is set to true), then we should not add a checkbox
// as it doesn't make sense.
if ( isset( $def['type'] ) && $def['type'] === 'info' ) {
$allPrefs[$pref] = $def;
continue;
}
// Create the new preference.
$isGlobal = isset( $globalPrefs[$pref] );
$allPrefs[$pref . static::GLOBAL_EXCEPTION_SUFFIX] = [
'type' => 'toggle',
// Make the tooltip and the label the same, because the label is normally hidden.
'tooltip' => 'globalprefs-check-label',
'label-message' => 'tooltip-globalprefs-check-label',
'default' => $isGlobal,
'section' => $def['section'],
'cssclass' => 'mw-globalprefs-global-check mw-globalprefs-checkbox-for-' . $pref,
'hide-if' => $def['hide-if'] ?? false,
'disable-if' => $def['disable-if'] ?? false,
];
if ( isset( $def['disable-if'] ) ) {
$def['disable-if'] = [ 'OR', $def['disable-if'],
[ '!==', $pref . static::GLOBAL_EXCEPTION_SUFFIX, '1' ]
];
} else {
$def['disable-if'] = [ '!==', $pref . static::GLOBAL_EXCEPTION_SUFFIX, '1' ];
}
// If this has a local exception, override it and append a help message to say so.
if ( $isGlobal
&& $userOptionsLookup->getBoolOption( $user, $pref . UserOptionsLookup::LOCAL_EXCEPTION_SUFFIX )
) {
$def['default'] = $this->getOptionFromUser( $pref, $def, $globalPrefs );
// Create a link to the relevant section of GlobalPreferences.
$secFragment = static::getSectionFragmentId( $def['section'] );
$helpMsg = [ 'globalprefs-has-local-exception', $secFragment ];
// Merge the help messages.
if ( isset( $def['help'] ) ) {
$def['help-messages'] = [ new RawMessage( $def['help'] . '<br />' ), $helpMsg ];
unset( $def['help'] );
} elseif ( isset( $def['help-message'] ) ) {
$def['help-messages'] = [ $def['help-message'], new RawMessage( '<br />' ), $helpMsg ];
unset( $def['help-message'] );
} elseif ( isset( $def['help-messages'] ) ) {
$def['help-messages'][] = new RawMessage( '<br />' );
$def['help-messages'][] = $helpMsg;
} else {
$def['help-message'] = $helpMsg;
}
}
$allPrefs[$pref] = $def;
}
return $allPrefs;
}
/**
* @inheritDoc
*/
protected function saveFormData( $formData, \PreferencesFormOOUI $form, array $formDescriptor ) {
if ( !$this->onGlobalPrefsPage( $form ) ) {
return parent::saveFormData( $formData, $form, $formDescriptor );
}
'@phan-var GlobalPreferencesFormOOUI $form';
$user = $form->getModifiedUser();
// Difference from parent: removed 'editmyprivateinfo'
if ( !$this->permissionManager->userHasRight( $user, 'editmyoptions' ) ) {
return Status::newFatal( 'mypreferencesprotected' );
}
// Filter input
$this->applyFilters( $formData, $formDescriptor, 'filterFromForm' );
// In the parent, we remove 'realname', but this is unnecessary
// here because GlobalPreferences removes this elsewhere, so
// the field will not even appear in this form
// Difference from parent: We are not collecting old user settings
foreach ( $this->getSaveBlacklist() as $b ) {
unset( $formData[$b] );
}
# If users have saved a value for a preference which has subsequently been disabled
# via $wgHiddenPrefs, we don't want to destroy that setting in case the preference
# is subsequently re-enabled
$hiddenPrefs = $this->options->get( 'HiddenPrefs' );
$userOptionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
foreach ( $hiddenPrefs as $pref ) {
# If the user has not set a non-default value here, the default will be returned
# and subsequently discarded
$formData[$pref] = $userOptionsLookup->getOption( $user, $pref, null, true );
}
// Difference from parent: We are ignoring RClimit preference; the parent
// checks for changes in that preference to update a hidden one, but the
// RCFilters product is okay with having that be localized
// We are also not resetting unused preferences in the global context.
// Otherwise, users could lose data by editing their global preferences
// on a wiki where some of the preferences don't exist. However, this means
// that preferences for undeployed extensions or removed code are never
// removed from the database...
// Setting the actual preference values:
$prefs = [];
$suffixLen = strlen( self::GLOBAL_EXCEPTION_SUFFIX );
foreach ( $formData as $name => $value ) {
// If this is the '-global' counterpart to a preference.
if ( self::isGlobalPrefName( $name ) && $value === true ) {
// Determine the real name of the preference.
$realName = substr( $name, 0, -$suffixLen );
if ( array_key_exists( $realName, $formData ) ) {
$prefs[$realName] = $formData[$realName];
if ( $prefs[$realName] === null ) {
// Special case: null means don't save this row, which can keep the previous value
$prefs[$realName] = '';
}
}
}
}
$matricesToClear = [];
// Now special processing for CheckMatrices
foreach ( $this->findCheckMatrices( $formDescriptor ) as $name ) {
$globalName = $name . self::GLOBAL_EXCEPTION_SUFFIX;
// Find all separate controls for this CheckMatrix
$checkMatrix = preg_grep( '/^' . preg_quote( $name ) . '/', array_keys( $formData ) );
if ( array_key_exists( $globalName, $formData ) && $formData[$globalName] ) {
// Setting is global, copy the checkmatrices
foreach ( $checkMatrix as $input ) {
$prefs[$input] = $formData[$input];
}
$prefs[$name] = true;
} else {
// Remove all the rows for this CheckMatrix
foreach ( $checkMatrix as $input ) {
unset( $prefs[$input] );
}
$matricesToClear[] = $name;
}
unset( $prefs[$globalName] );
}
$this->setGlobalPreferences( $user, $prefs, $form->getContext(), $matricesToClear );
return true;
}
/**
* Finds CheckMatrix inputs in a form descriptor
*
* @param array $formDescriptor
* @return string[] Names of CheckMatrix options (parent only, not sub-checkboxes)
*/
private function findCheckMatrices( array $formDescriptor ) {
$result = [];
foreach ( $formDescriptor as $name => $info ) {
if ( ( isset( $info['type'] ) && $info['type'] == 'checkmatrix' ) ||
// Checking old alias for compatibility with unchanged extensions
( isset( $info['class'] ) && $info['class'] == \HTMLCheckMatrix::class ) ||
( isset( $info['class'] ) && $info['class'] == HTMLCheckMatrix::class )
) {
$result[] = $name;
}
}
return $result;
}
/**
* Get the HTML fragment identifier for a given preferences section. This is the leading part
* of the provided section name, up to a slash (if there is one).
* @param string $section A section name, as used in a preference definition.
* @return string
*/
public static function getSectionFragmentId( $section ) {
$sectionId = preg_replace( '#/.*$#', '', $section );
return 'mw-prefsection-' . $sectionId;
}
/**
* Checks whether the given preference is globalizable.
*
* @param string $name Preference name
* @param mixed[] &$info Preference description, by reference to avoid unnecessary cloning
* @return bool
*/
protected function isGlobalizablePreference( $name, &$info ) {
// Preferences can opt out of being globalized by setting the 'noglobal' flag.
if ( isset( $info['noglobal'] ) && $info['noglobal'] === true ) {
return false;
}
// Ignore "is global" checkboxes
if ( static::isGlobalPrefName( $name ) ) {
return false;
}
// If a setting can't be changed, don't bother globalizing it
if ( isset( $info['disabled'] ) && $info['disabled'] ) {
return false;
}
// Allow explicitly define if a preference can be globalized,
// especially useful for custom field classes.
// TODO: Deprecate 'noglobal' in favour of this param.
if ( isset( $info['canglobal'] ) ) {
return (bool)$info['canglobal'];
}
$isAllowedType = isset( $info['type'] )
&& !in_array( $info['type'], $this->typesPrevented )
&& !in_array( $name, $this->disallowedPreferences );
$isAllowedClass = isset( $info['class'] )
&& in_array( $info['class'], $this->allowedClasses );
return $isAllowedType || $isAllowedClass;
}
/**
* A convenience function to check if a preference name is for a global one.
* @param string $name The name to check.
* @return bool
*/
public static function isGlobalPrefName( $name ) {
return str_ends_with( $name, static::GLOBAL_EXCEPTION_SUFFIX );
}
/**
* A convenience function to check if a preference name is for a local-exception preference.
* @param string $name The name to check.
* @return bool
*/
public static function isLocalPrefName( $name ) {
return str_ends_with( $name, UserOptionsLookup::LOCAL_EXCEPTION_SUFFIX );
}
/**
* Checks if the user is globalized.
* @param UserIdentity $user
* @return bool
*/
public function isUserGlobalized( UserIdentity $user ) {
$utils = MediaWikiServices::getInstance()->getUserIdentityUtils();
return $utils->isNamed( $user ) && $this->getUserID( $user ) !== 0;
}
/**
* Gets the user's ID that we're using in the table
* Returns 0 if the user is not global
* @param UserIdentity $user
* @return int
*/
public function getUserID( UserIdentity $user ) {
// Implement a single-element cache where $this->globalUserRef holds the
// cache key of the currently-stored item and $this->globalUserId is
// the value.
if ( !$this->globalUserRef || $this->globalUserRef->get() !== $user ) {
$lookup = MediaWikiServices::getInstance()->getCentralIdLookup();
$this->globalUserRef = WeakReference::create( $user );
$this->globalUserId = $lookup->isOwned( $user ) ?
$lookup->centralIdFromName( $user->getName(), CentralIdLookup::AUDIENCE_RAW ) :
0;
}
return $this->globalUserId;
}
/**
* Get the user's global preferences.
* @param UserIdentity $user
* @param bool $skipCache Whether the preferences should be loaded strictly from DB
* @return false|string[] Array keyed by preference name, or false if not found.
*/
public function getGlobalPreferencesValues( UserIdentity $user, $skipCache = false ) {
$id = $this->getUserID( $user );
if ( !$id ) {
$this->logger->warning( "Couldn't find a global ID for user {user}",
[ 'user' => $user->getName() ]
);
return false;
}
$storage = $this->makeStorage( $user );
return $storage->load( $skipCache );
}
/**
* Save the user's global preferences.
* @param User $user
* @param array $newGlobalPrefs Array keyed by preference name.
* @param IContextSource $context The request context.
* @param string[] $checkMatricesToClear List of check matrix controls that
* need their rows purged
* @return bool True on success, false if the user isn't global.
*/
public function setGlobalPreferences(
User $user,
$newGlobalPrefs,
IContextSource $context,
array $checkMatricesToClear = []
) {
$id = $this->getUserID( $user );
if ( !$id ) {
return false;
}
// Use a new instance of the current user to fetch the form descriptor because that way
// we're working with the previous user options and not those that are currently in the
// process of being saved (we only want the option names here, so don't care what the
// values are).
$userForDescriptor = MediaWikiServices::getInstance()->getUserFactory()->newFromId( $user->getId() );
// Save the global options.
$storage = $this->makeStorage( $user );
$knownPrefs = array_keys( $this->getFormDescriptor( $userForDescriptor, $context ) );
$oldPreferences = $this->getGlobalPreferencesValues( $user );
$storage->save( $newGlobalPrefs, $knownPrefs, $checkMatricesToClear );
$this->globalPreferencesHookRunner->onGlobalPreferencesSetGlobalPreferences(
$user,
$oldPreferences,
$newGlobalPrefs
);
$user->clearInstanceCache();
return true;
}
/**
* Deletes all of a user's global preferences.
* Assumes that the user is globalized.
* @param User $user
*/
public function resetGlobalUserSettings( User $user ) {
$oldPreferences = $this->getGlobalPreferencesValues( $user );
$this->makeStorage( $user )->delete();
$this->globalPreferencesHookRunner->onGlobalPreferencesSetGlobalPreferences(
$user,
$oldPreferences,
[]
);
}
/**
* Convenience function to check if we're on the global prefs page.
* @param IContextSource|null $context The context to use; if not set main request context is used.
* @return bool
*/
public function onGlobalPrefsPage( $context = null ) {
$context = $context ?: RequestContext::getMain();
return $context->getTitle() && $context->getTitle()->isSpecial( 'GlobalPreferences' );
}
/**
* Convenience function to check if we're on the local prefs page.
*
* @param IContextSource|null $context The context to use; if not set main request context is used.
* @return bool
*/
public function onLocalPrefsPage( $context = null ) {
$context = $context ?: RequestContext::getMain();
return $context->getTitle() && $context->getTitle()->isSpecial( 'Preferences' );
}
/**
* Processes local user options before they're saved
*
* @param UserIdentity $user
* @param array &$modifiedOptions
* @param array $originalOptions
*/
public function handleLocalPreferencesChange(
UserIdentity $user,
array &$modifiedOptions,
array $originalOptions
) {
$shouldModify = [];
$mergedOptions = array_merge( $originalOptions, $modifiedOptions );
foreach ( $this->autoGlobals as $optName ) {
// $modifiedOptions can contains options that not actually modified, filter out them
if ( array_key_exists( $optName, $modifiedOptions ) &&
( !array_key_exists( $optName, $originalOptions ) ||
$modifiedOptions[$optName] !== $originalOptions[$optName] ) &&
// And skip options that have local exceptions
!( $mergedOptions[$optName . UserOptionsLookup::LOCAL_EXCEPTION_SUFFIX] ?? false )
) {
$shouldModify[$optName] = $modifiedOptions[$optName];
}
}
// No auto-global options are modified
if ( !$shouldModify ) {
return;
}
$preferencesChanged = false;
$globals = $this->getGlobalPreferencesValues( $user, true );
if ( $globals ) {
foreach ( $shouldModify as $optName => $optVal ) {
if ( array_key_exists( $optName, $globals ) ) {
$globals[$optName] = $optVal;
$preferencesChanged = true;
}
}
if ( $preferencesChanged ) {
$this->makeStorage( $user )->save( $globals, array_keys( $globals ) );
}
}
}
/**
* Factory for preference storage
*
* @param UserIdentity $user
* @return Storage
*/
protected function makeStorage( UserIdentity $user ) {
$id = $this->getUserID( $user );
if ( !$id ) {
throw new LogicException( 'User not set or is not global on call to ' . __METHOD__ );
}
return new Storage( $id );
}
}

File Metadata

Mime Type
text/x-php
Expires
Aug 20 2026, 02:01 (10 w, 3 d ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
aa/cb/a27be0ede49f4ad31c910eb5fa06
Default Alt Text
GlobalPreferencesFactory.php (23 KB)

Event Timeline