From ce3960ec31385df676d058a2c3b5de63d014f1c3 Mon Sep 17 00:00:00 2001 From: David Barratt Date: Tue, 29 Aug 2017 09:43:35 -0400 Subject: [PATCH] Allow users to restrict who can send them direct emails via Special:EmailUser Users can now specify a blacklist of users who are prevented from sending them a direct email. Bug: T138166 Change-Id: Ifa26153f593b0ca3a9121e1e29961911c616c9e4 --- RELEASE-NOTES-1.30 | 5 +++ includes/DefaultSettings.php | 7 ++++ includes/Preferences.php | 16 ++++++++ includes/api/ApiEmailUser.php | 2 +- includes/skins/Skin.php | 8 ++-- includes/specials/SpecialEmailuser.php | 52 +++++++++++++++++++++----- includes/user/CentralIdLookup.php | 42 +++++++++++++++++++++ includes/user/User.php | 27 +++++++++++++ languages/i18n/en.json | 1 + languages/i18n/qqq.json | 1 + 10 files changed, 147 insertions(+), 14 deletions(-) diff --git a/RELEASE-NOTES-1.30 b/RELEASE-NOTES-1.30 index 67a449a312..8517a8f0de 100644 --- a/RELEASE-NOTES-1.30 +++ b/RELEASE-NOTES-1.30 @@ -70,6 +70,9 @@ section). ** This is currently gated by $wgCommentTableSchemaMigrationStage. Most wikis can set this to MIGRATION_NEW and run maintenance/migrateComments.php as soon as any necessary extensions are updated. +* (T138166) Added ability for users to prohibit other users from sending them + emails with Special:Emailuser. Can be enabled by setting + $wgEnableUserEmailBlacklist to true. === External library changes in 1.30 === @@ -216,6 +219,8 @@ changes to languages because of Phabricator reports. * wfUsePHP() is deprecated. * wfFixSessionID() was removed. * wfShellExec() and related functions are deprecated, use Shell::command(). +* (T138166) SpecialEmailUser::getTarget() now requires a second argument, the sending + user object. Using the method without the second argument is deprecated. == Compatibility == MediaWiki 1.30 requires PHP 5.5.9 or later. There is experimental support for diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 5b77d16c4e..40bcf79635 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -1603,6 +1603,13 @@ $wgEnableEmail = true; */ $wgEnableUserEmail = true; +/** + * Set to true to enable user-to-user e-mail blacklist. + * + * @since 1.30 + */ +$wgEnableUserEmailBlacklist = false; + /** * If true put the sending user's email in a Reply-To header * instead of From (false). ($wgPasswordSender will be used as From.) diff --git a/includes/Preferences.php b/includes/Preferences.php index 0bb1d28af3..3db6d0fbcd 100644 --- a/includes/Preferences.php +++ b/includes/Preferences.php @@ -554,6 +554,22 @@ class Preferences { 'label-message' => 'tog-ccmeonemails', 'disabled' => $disableEmailPrefs, ]; + + if ( $config->get( 'EnableUserEmailBlacklist' ) + && !$disableEmailPrefs + && !(bool)$user->getOption( 'disablemail' ) + ) { + $lookup = CentralIdLookup::factory(); + $ids = $user->getOption( 'email-blacklist', [] ); + $names = $ids ? $lookup->namesFromCentralIds( $ids, $user ) : []; + + $defaultPreferences['email-blacklist'] = [ + 'type' => 'usersmultiselect', + 'label-message' => 'email-blacklist-label', + 'section' => 'personal/email', + 'default' => implode( "\n", $names ), + ]; + } } if ( $config->get( 'EnotifWatchlist' ) ) { diff --git a/includes/api/ApiEmailUser.php b/includes/api/ApiEmailUser.php index 4b4b76bbc0..edea2661a2 100644 --- a/includes/api/ApiEmailUser.php +++ b/includes/api/ApiEmailUser.php @@ -34,7 +34,7 @@ class ApiEmailUser extends ApiBase { $params = $this->extractRequestParams(); // Validate target - $targetUser = SpecialEmailUser::getTarget( $params['target'] ); + $targetUser = SpecialEmailUser::getTarget( $params['target'], $this->getUser() ); if ( !( $targetUser instanceof User ) ) { switch ( $targetUser ) { case 'notarget': diff --git a/includes/skins/Skin.php b/includes/skins/Skin.php index eaee0d2572..40aa247370 100644 --- a/includes/skins/Skin.php +++ b/includes/skins/Skin.php @@ -1057,10 +1057,10 @@ abstract class Skin extends ContextSource { $targetUser = User::newFromId( $id ); } - # The sending user must have a confirmed email address and the target - # user must have a confirmed email address and allow emails from users. - return $this->getUser()->canSendEmail() && - $targetUser->canReceiveEmail(); + # The sending user must have a confirmed email address and the receiving + # user must accept emails from the sender. + return $this->getUser()->canSendEmail() + && SpecialEmailUser::validateTarget( $targetUser, $this->getUser() ) === ''; } /** diff --git a/includes/specials/SpecialEmailuser.php b/includes/specials/SpecialEmailuser.php index 830b43817f..249be7f17f 100644 --- a/includes/specials/SpecialEmailuser.php +++ b/includes/specials/SpecialEmailuser.php @@ -44,7 +44,7 @@ class SpecialEmailUser extends UnlistedSpecialPage { } public function getDescription() { - $target = self::getTarget( $this->mTarget ); + $target = self::getTarget( $this->mTarget, $this->getUser() ); if ( !$target instanceof User ) { return $this->msg( 'emailuser-title-notarget' )->text(); } @@ -142,7 +142,7 @@ class SpecialEmailUser extends UnlistedSpecialPage { throw new ErrorPageError( $title, $msg, $params ); } // Got a valid target user name? Else ask for one. - $ret = self::getTarget( $this->mTarget ); + $ret = self::getTarget( $this->mTarget, $this->getUser() ); if ( !$ret instanceof User ) { if ( $this->mTarget != '' ) { // Messages used here: notargettext, noemailtext, nowikiemailtext @@ -187,9 +187,14 @@ class SpecialEmailUser extends UnlistedSpecialPage { * Validate target User * * @param string $target Target user name - * @return User User object on success or a string on error + * @param User|null $sender User sending the email + * @return User|string User object on success or a string on error */ - public static function getTarget( $target ) { + public static function getTarget( $target, User $sender = null ) { + if ( $sender === null ) { + wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' ); + } + if ( $target == '' ) { wfDebug( "Target is empty.\n" ); @@ -197,21 +202,50 @@ class SpecialEmailUser extends UnlistedSpecialPage { } $nu = User::newFromName( $target ); - if ( !$nu instanceof User || !$nu->getId() ) { + $error = self::validateTarget( $nu, $sender ); + + return $error ? $error : $nu; + } + + /** + * Validate target User + * + * @param User $target Target user + * @param User|null $sender User sending the email + * @return string Error message or empty string if valid. + * @since 1.30 + */ + public static function validateTarget( $target, User $sender = null ) { + if ( $sender === null ) { + wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' ); + } + + if ( !$target instanceof User || !$target->getId() ) { wfDebug( "Target is invalid user.\n" ); return 'notarget'; - } elseif ( !$nu->isEmailConfirmed() ) { + } elseif ( !$target->isEmailConfirmed() ) { wfDebug( "User has no valid email.\n" ); return 'noemail'; - } elseif ( !$nu->canReceiveEmail() ) { + } elseif ( !$target->canReceiveEmail() ) { wfDebug( "User does not allow user emails.\n" ); return 'nowikiemail'; + } elseif ( $sender !== null ) { + $blacklist = $target->getOption( 'email-blacklist', [] ); + if ( $blacklist ) { + $lookup = CentralIdLookup::factory(); + $senderId = $lookup->centralIdFromLocalUser( $sender ); + if ( $senderId !== 0 && in_array( $senderId, $blacklist ) ) { + wfDebug( "User does not allow user emails from this user.\n" ); + + return 'nowikiemail'; + } + } } - return $nu; + return ''; } /** @@ -326,7 +360,7 @@ class SpecialEmailUser extends UnlistedSpecialPage { public static function submit( array $data, IContextSource $context ) { $config = $context->getConfig(); - $target = self::getTarget( $data['Target'] ); + $target = self::getTarget( $data['Target'], $context->getUser() ); if ( !$target instanceof User ) { // Messages used here: notargettext, noemailtext, nowikiemailtext return Status::newFatal( $target . 'text' ); diff --git a/includes/user/CentralIdLookup.php b/includes/user/CentralIdLookup.php index 2ced6e2516..618b7f07ed 100644 --- a/includes/user/CentralIdLookup.php +++ b/includes/user/CentralIdLookup.php @@ -157,6 +157,27 @@ abstract class CentralIdLookup implements IDBAccessObject { return $idToName[$id]; } + /** + * Given a an array of central user IDs, return the (local) user names. + * @param int[] $ids Central user IDs + * @param int|User $audience One of the audience constants, or a specific user + * @param int $flags IDBAccessObject read flags + * @return string[] User names + * @since 1.30 + */ + public function namesFromCentralIds( + array $ids, $audience = self::AUDIENCE_PUBLIC, $flags = self::READ_NORMAL + ) { + $idToName = array_fill_keys( $ids, false ); + $names = $this->lookupCentralIds( $idToName, $audience, $flags ); + $names = array_unique( $names ); + $names = array_filter( $names, function ( $name ) { + return $name !== false && $name !== ''; + } ); + + return array_values( $names ); + } + /** * Given a (local) user name, return the central ID * @note There's no requirement that the user name actually exists locally, @@ -174,6 +195,27 @@ abstract class CentralIdLookup implements IDBAccessObject { return $nameToId[$name]; } + /** + * Given an array of (local) user names, return the central IDs. + * @param string[] $names Canonicalized user names + * @param int|User $audience One of the audience constants, or a specific user + * @param int $flags IDBAccessObject read flags + * @return int[] User IDs + * @since 1.30 + */ + public function centralIdsFromNames( + array $names, $audience = self::AUDIENCE_PUBLIC, $flags = self::READ_NORMAL + ) { + $nameToId = array_fill_keys( $names, false ); + $ids = $this->lookupUserNames( $nameToId, $audience, $flags ); + $ids = array_unique( $ids ); + $ids = array_filter( $ids, function ( $id ) { + return $id !== false; + } ); + + return array_values( $ids ); + } + /** * Given a central user ID, return a local User object * @note Unlike nameFromCentralId(), this does guarantee that the local diff --git a/includes/user/User.php b/includes/user/User.php index 0c39610b24..6115144d1b 100644 --- a/includes/user/User.php +++ b/includes/user/User.php @@ -5317,6 +5317,13 @@ class User implements IDBAccessObject { $data[$row->up_property] = $row->up_value; } } + + // Convert the email blacklist from a new line delimited string + // to an array of ids. + if ( isset( $data['email-blacklist'] ) && $data['email-blacklist'] ) { + $data['email-blacklist'] = array_map( 'intval', explode( "\n", $data['email-blacklist'] ) ); + } + foreach ( $data as $property => $value ) { $this->mOptionOverrides[$property] = $value; $this->mOptions[$property] = $value; @@ -5339,6 +5346,26 @@ class User implements IDBAccessObject { // Not using getOptions(), to keep hidden preferences in database $saveOptions = $this->mOptions; + // Convert usernames to ids. + if ( isset( $this->mOptions['email-blacklist'] ) ) { + if ( $this->mOptions['email-blacklist'] ) { + $value = $this->mOptions['email-blacklist']; + // Email Blacklist may be an array of ids or a string of new line + // delimnated user names. + if ( is_array( $value ) ) { + $ids = array_filter( $value, 'is_numeric' ); + } else { + $lookup = CentralIdLookup::factory(); + $ids = $lookup->centralIdsFromNames( explode( "\n", $value ), $this ); + } + $this->mOptions['email-blacklist'] = $ids; + $saveOptions['email-blacklist'] = implode( "\n", $this->mOptions['email-blacklist'] ); + } else { + // If the blacklist is empty, set it to null rather than an empty string. + $this->mOptions['email-blacklist'] = null; + } + } + // Allow hooks to abort, for instance to save to a global profile. // Reset options to default state before saving. if ( !Hooks::run( 'UserSaveOptions', [ $this, &$saveOptions ] ) ) { diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 5dd83453af..24e1818e89 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -1063,6 +1063,7 @@ "timezoneregion-indian": "Indian Ocean", "timezoneregion-pacific": "Pacific Ocean", "allowemail": "Enable email from other users", + "email-blacklist-label": "Prohibit these users from sending emails to me:", "prefs-searchoptions": "Search", "prefs-namespaces": "Namespaces", "default": "default", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 9d2e77dcba..208c7c3370 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1254,6 +1254,7 @@ "timezoneregion-indian": "Used in \"Time zone\" listbox in [[Special:Preferences#mw-prefsection-datetime|preferences]], \"date and time\" tab.\n{{Related|Timezoneregion}}", "timezoneregion-pacific": "Used in \"Time zone\" listbox in [[Special:Preferences#mw-prefsection-datetime|preferences]], \"date and time\" tab.\n{{Related|Timezoneregion}}", "allowemail": "Used in [[Special:Preferences]] > {{int:prefs-personal}} > {{int:email}}.", + "email-blacklist-label": "Used in [[Special:Preferences]] > {{int:prefs-prohibit}} > {{int:email}}.", "prefs-searchoptions": "{{Identical|Search}}", "prefs-namespaces": "Shown as legend of the second fieldset of the tab 'Search' in [[Special:Preferences]]\n{{Identical|Namespace}}", "default": "{{Identical|Default}}", -- 2.20.1