Merge "Improve documentation of constants throughout the codebase"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Fri, 12 Apr 2019 21:38:16 +0000 (21:38 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Fri, 12 Apr 2019 21:38:16 +0000 (21:38 +0000)
1  2 
includes/block/Restriction/AbstractRestriction.php
includes/media/DjVuImage.php
includes/search/SearchEngine.php
includes/user/User.php
includes/watcheditem/NoWriteWatchedItemStore.php

@@@ -25,12 -25,14 +25,14 @@@ namespace MediaWiki\Block\Restriction
  abstract class AbstractRestriction implements Restriction {
  
        /**
-        * @var string
+        * String constant identifying the type of restriction. Expected to be overriden in subclasses
+        * with a non-empty string value.
         */
        const TYPE = '';
  
        /**
-        * @var int
+        * Numeric type identifier. Expected to be overriden in subclasses with a non-zero integer
+        * number. Must not exceed 127 to fit into a TINYINT database field.
         */
        const TYPE_ID = 0;
  
        }
  
        /**
 -       * {@inheritdoc}
 +       * @inheritDoc
         */
        public static function getType() {
                return static::TYPE;
        }
  
        /**
 -       * {@inheritdoc}
 +       * @inheritDoc
         */
        public static function getTypeId() {
                return static::TYPE_ID;
        }
  
        /**
 -       * {@inheritdoc}
 +       * @inheritDoc
         */
        public function getBlockId() {
                return $this->blockId;
        }
  
        /**
 -       * {@inheritdoc}
 +       * @inheritDoc
         */
        public function setBlockId( $blockId ) {
                $this->blockId = (int)$blockId;
        }
  
        /**
 -       * {@inheritdoc}
 +       * @inheritDoc
         */
        public function getValue() {
                return $this->value;
        }
  
        /**
 -       * {@inheritdoc}
 +       * @inheritDoc
         */
        public static function newFromRow( \stdClass $row ) {
 +              // @phan-suppress-next-line PhanTypeInstantiateAbstract
                return new static( $row->ir_ipb_id, $row->ir_value );
        }
  
        /**
 -       * {@inheritdoc}
 +       * @inheritDoc
         */
        public function toRow() {
                return [
        }
  
        /**
 -       * {@inheritdoc}
 +       * @inheritDoc
         */
        public function equals( Restriction $other ) {
                return $this->getHash() === $other->getHash();
        }
  
        /**
 -       * {@inheritdoc}
 +       * @inheritDoc
         */
        public function getHash() {
                return $this->getType() . '-' . $this->getValue();
@@@ -24,8 -24,6 +24,8 @@@
   * @ingroup Media
   */
  
 +use MediaWiki\Shell\Shell;
 +
  /**
   * Support for detecting/validating DjVu image files and getting
   * some basic file metadata (resolution etc)
@@@ -36,8 -34,9 +36,9 @@@
   * @ingroup Media
   */
  class DjVuImage {
        /**
-        * @const DJVUTXT_MEMORY_LIMIT Memory limit for the DjVu description software
+        * Memory limit for the DjVu description software
         */
        const DJVUTXT_MEMORY_LIMIT = 300000;
  
                                $this->dumpForm( $file, $chunkLength, $indent + 1 );
                        } else {
                                fseek( $file, $chunkLength, SEEK_CUR );
 -                              if ( $chunkLength & 1 == 1 ) {
 +                              if ( ( $chunkLength & 1 ) == 1 ) {
                                        // Padding byte between chunks
                                        fseek( $file, 1, SEEK_CUR );
                                }
        private function skipChunk( $file, $chunkLength ) {
                fseek( $file, $chunkLength, SEEK_CUR );
  
 -              if ( $chunkLength & 0x01 == 1 && !feof( $file ) ) {
 +              if ( ( $chunkLength & 0x01 ) == 1 && !feof( $file ) ) {
                        // padding byte
                        fseek( $file, 1, SEEK_CUR );
                }
                if ( isset( $wgDjvuDump ) ) {
                        # djvudump is faster as of version 3.5
                        # https://sourceforge.net/p/djvu/bugs/71/
 -                      $cmd = wfEscapeShellArg( $wgDjvuDump ) . ' ' . wfEscapeShellArg( $this->mFilename );
 +                      $cmd = Shell::escape( $wgDjvuDump ) . ' ' . Shell::escape( $this->mFilename );
                        $dump = wfShellExec( $cmd );
                        $xml = $this->convertDumpToXML( $dump );
                } elseif ( isset( $wgDjvuToXML ) ) {
 -                      $cmd = wfEscapeShellArg( $wgDjvuToXML ) . ' --without-anno --without-text ' .
 -                              wfEscapeShellArg( $this->mFilename );
 +                      $cmd = Shell::escape( $wgDjvuToXML ) . ' --without-anno --without-text ' .
 +                              Shell::escape( $this->mFilename );
                        $xml = wfShellExec( $cmd );
                } else {
                        $xml = null;
                }
                # Text layer
                if ( isset( $wgDjvuTxt ) ) {
 -                      $cmd = wfEscapeShellArg( $wgDjvuTxt ) . ' --detail=page ' . wfEscapeShellArg( $this->mFilename );
 +                      $cmd = Shell::escape( $wgDjvuTxt ) . ' --detail=page ' . Shell::escape( $this->mFilename );
                        wfDebug( __METHOD__ . ": $cmd\n" );
                        $retval = '';
                        $txt = wfShellExec( $cmd, $retval, [], [ 'memory' => self::DJVUTXT_MEMORY_LIMIT ] );
  EOR;
                                $txt = preg_replace_callback( $reg, [ $this, 'pageTextCallback' ], $txt );
                                $txt = "<DjVuTxt>\n<HEAD></HEAD>\n<BODY>\n" . $txt . "</BODY>\n</DjVuTxt>\n";
 -                              $xml = preg_replace( "/<DjVuXML>/", "<mw-djvu><DjVuXML>", $xml, 1 );
 -                              $xml = $xml . $txt . '</mw-djvu>';
 +                              $xml = preg_replace( "/<DjVuXML>/", "<mw-djvu><DjVuXML>", $xml, 1 ) .
 +                                      $txt .
 +                                      '</mw-djvu>';
                        }
                }
  
@@@ -56,16 -56,16 +56,16 @@@ abstract class SearchEngine 
        /** @var array Feature values */
        protected $features = [];
  
-       /** @const string profile type for completionSearch */
+       /** Profile type for completionSearch */
        const COMPLETION_PROFILE_TYPE = 'completionSearchProfile';
  
-       /** @const string profile type for query independent ranking features */
+       /** Profile type for query independent ranking features */
        const FT_QUERY_INDEP_PROFILE_TYPE = 'fulltextQueryIndepProfile';
  
-       /** @const int flag for legalSearchChars: includes all chars allowed in a search query */
+       /** Integer flag for legalSearchChars: includes all chars allowed in a search query */
        const CHARS_ALL = 1;
  
-       /** @const int flag for legalSearchChars: includes all chars allowed in a search term */
+       /** Integer flag for legalSearchChars: includes all chars allowed in a search term */
        const CHARS_NO_SYNTAX = 2;
  
        /**
  
        /**
         * Get chars legal for search
 -       * NOTE: usage as static is deprecated and preserved only as BC measure
         * @param int $type type of search chars (see self::CHARS_ALL
         * and self::CHARS_NO_SYNTAX). Defaults to CHARS_ALL
         * @return string
         */
 -      public static function legalSearchChars( $type = self::CHARS_ALL ) {
 +      public function legalSearchChars( $type = self::CHARS_ALL ) {
                return "A-Za-z_'.0-9\\x80-\\xFF\\-";
        }
  
                }
        }
  }
 -
 -/**
 - * Dummy class to be used when non-supported Database engine is present.
 - * @todo FIXME: Dummy class should probably try something at least mildly useful,
 - * such as a LIKE search through titles.
 - * @ingroup Search
 - */
 -class SearchEngineDummy extends SearchEngine {
 -      // no-op
 -}
diff --combined includes/user/User.php
@@@ -28,7 -28,6 +28,7 @@@ use MediaWiki\Auth\AuthenticationRespon
  use MediaWiki\Auth\AuthenticationRequest;
  use MediaWiki\User\UserIdentity;
  use MediaWiki\Logger\LoggerFactory;
 +use Wikimedia\Assert\Assert;
  use Wikimedia\IPSet;
  use Wikimedia\ScopedCallback;
  use Wikimedia\Rdbms\Database;
@@@ -46,18 -45,20 +46,20 @@@ use Wikimedia\Rdbms\IDatabase
   * of the database.
   */
  class User implements IDBAccessObject, UserIdentity {
        /**
-        * @const int Number of characters in user_token field.
+        * Number of characters required for the user_token field.
         */
        const TOKEN_LENGTH = 32;
  
        /**
-        * @const string An invalid value for user_token
+        * An invalid string value for the user_token field.
         */
        const INVALID_TOKEN = '*** INVALID ***';
  
        /**
-        * @const int Serialized record version.
+        * Version number to tag cached versions of serialized User objects. Should be increased when
+        * {@link $mCacheVars} or one of it's members changes.
         */
        const VERSION = 13;
  
         * - forceChange (bool): if set to true, the user should not be
         *   allowed to log with this password unless they change it during
         *   the login process (see ResetPasswordSecondaryAuthenticationProvider).
 +       * - suggestChangeOnLogin (bool): if set to true, the user should be prompted for
 +       *   a password change on login.
         *
         * @param string $password Desired password
         * @return Status
                        return false;
                }
  
 -              // Reject various classes of invalid names
 -              $name = AuthManager::callLegacyAuthPlugin(
 -                      'getCanonicalName', [ $t->getText() ], $t->getText()
 -              );
 +              $name = $t->getText();
  
                switch ( $validate ) {
                        case false:
         */
        public function trackBlockWithCookie() {
                $block = $this->getBlock();
 -              if ( $block && $this->getRequest()->getCookie( 'BlockID' ) === null ) {
 -                      $config = RequestContext::getMain()->getConfig();
 -                      $shouldSetCookie = false;
 -
 -                      if ( $this->isAnon() && $config->get( 'CookieSetOnIpBlock' ) ) {
 -                              // If user is logged-out, set a cookie to track the Block
 -                              $shouldSetCookie = in_array( $block->getType(), [
 -                                      Block::TYPE_IP, Block::TYPE_RANGE
 -                              ] );
 -                              if ( $shouldSetCookie ) {
 -                                      $block->setCookie( $this->getRequest()->response() );
  
 -                                      // temporary measure the use of cookies on ip blocks
 -                                      $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
 -                                      $stats->increment( 'block.ipblock.setCookie.success' );
 -                              }
 -                      } elseif ( $this->isLoggedIn() && $config->get( 'CookieSetOnAutoblock' ) ) {
 -                              $shouldSetCookie = $block->getType() === Block::TYPE_USER && $block->isAutoblocking();
 -                              if ( $shouldSetCookie ) {
 -                                      $block->setCookie( $this->getRequest()->response() );
 -                              }
 -                      }
 +              if ( $block && $this->getRequest()->getCookie( 'BlockID' ) === null
 +                      && $block->shouldTrackWithCookie( $this->isAnon() )
 +              ) {
 +                      $block->setCookie( $this->getRequest()->response() );
                }
        }
  
  
                // update groups in external authentication database
                Hooks::run( 'UserGroupsChanged', [ $this, $toPromote, [], false, false, $oldUGMs, $newUGMs ] );
 -              AuthManager::callLegacyAuthPlugin( 'updateExternalDBGroups', [ $this, $toPromote ] );
  
                $logEntry = new ManualLogEntry( 'rights', 'autopromote' );
                $logEntry->setPerformer( $this );
         * protected against race conditions using a compare-and-set (CAS) mechanism
         * based on comparing $this->mTouched with the user_touched field.
         *
 -       * @param Database $db
 +       * @param IDatabase $db
         * @param array $conditions WHERE conditions for use with Database::update
         * @return array WHERE conditions for use with Database::update
         */
 -      protected function makeUpdateConditions( Database $db, array $conditions ) {
 +      protected function makeUpdateConditions( IDatabase $db, array $conditions ) {
                if ( $this->mTouched ) {
                        // CAS check: only update if the row wasn't changed sicne it was loaded.
                        $conditions['user_touched'] = $db->timestamp( $this->mTouched );
                }
        }
  
 +      /** @var array|null */
 +      private static $defOpt = null;
 +      /** @var string|null */
 +      private static $defOptLang = null;
 +
 +      /**
 +       * Reset the process cache of default user options. This is only necessary
 +       * if the wiki configuration has changed since defaults were calculated,
 +       * and as such should only be performed inside the testing suite that
 +       * regularly changes wiki configuration.
 +       */
 +      public static function resetGetDefaultOptionsForTestsOnly() {
 +              Assert::invariant( defined( 'MW_PHPUNIT_TEST' ), 'Unit tests only' );
 +              self::$defOpt = null;
 +              self::$defOptLang = null;
 +      }
 +
        /**
         * Combine the language default options with any site-specific options
         * and add the default language variants.
        public static function getDefaultOptions() {
                global $wgNamespacesToBeSearchedDefault, $wgDefaultUserOptions, $wgDefaultSkin;
  
 -              static $defOpt = null;
 -              static $defOptLang = null;
 -
                $contLang = MediaWikiServices::getInstance()->getContentLanguage();
 -              if ( $defOpt !== null && $defOptLang === $contLang->getCode() ) {
 +              if ( self::$defOpt !== null && self::$defOptLang === $contLang->getCode() ) {
                        // The content language does not change (and should not change) mid-request, but the
                        // unit tests change it anyway, and expect this method to return values relevant to the
                        // current content language.
 -                      return $defOpt;
 +                      return self::$defOpt;
                }
  
 -              $defOpt = $wgDefaultUserOptions;
 +              self::$defOpt = $wgDefaultUserOptions;
                // Default language setting
 -              $defOptLang = $contLang->getCode();
 -              $defOpt['language'] = $defOptLang;
 +              self::$defOptLang = $contLang->getCode();
 +              self::$defOpt['language'] = self::$defOptLang;
                foreach ( LanguageConverter::$languagesWithVariants as $langCode ) {
                        if ( $langCode === $contLang->getCode() ) {
 -                              $defOpt['variant'] = $langCode;
 +                              self::$defOpt['variant'] = $langCode;
                        } else {
 -                              $defOpt["variant-$langCode"] = $langCode;
 +                              self::$defOpt["variant-$langCode"] = $langCode;
                        }
                }
  
                // since extensions may change the set of searchable namespaces depending
                // on user groups/permissions.
                foreach ( $wgNamespacesToBeSearchedDefault as $nsnum => $val ) {
 -                      $defOpt['searchNs' . $nsnum] = (bool)$val;
 +                      self::$defOpt['searchNs' . $nsnum] = (bool)$val;
                }
 -              $defOpt['skin'] = Skin::normalizeKey( $wgDefaultSkin );
 +              self::$defOpt['skin'] = Skin::normalizeKey( $wgDefaultSkin );
  
 -              Hooks::run( 'UserGetDefaultOptions', [ &$defOpt ] );
 +              Hooks::run( 'UserGetDefaultOptions', [ &self::$defOpt ] );
  
 -              return $defOpt;
 +              return self::$defOpt;
        }
  
        /**
  
        /**
         * Get blocking information
 -       * @param bool $bFromReplica Whether to check the replica DB first.
 +       * @param bool $fromReplica Whether to check the replica DB first.
         *   To improve performance, non-critical checks are done against replica DBs.
         *   Check when actually saving should be done against master.
         */
 -      private function getBlockedStatus( $bFromReplica = true ) {
 -              global $wgProxyWhitelist, $wgUser, $wgApplyIpBlocksToXff, $wgSoftBlockRanges;
 +      private function getBlockedStatus( $fromReplica = true ) {
 +              global $wgProxyWhitelist, $wgApplyIpBlocksToXff, $wgSoftBlockRanges;
  
                if ( $this->mBlockedby != -1 ) {
                        return;
                # user is not immune to autoblocks/hardblocks, and they are the current user so we
                # know which IP address they're actually coming from
                $ip = null;
 -              if ( !$this->isAllowed( 'ipblock-exempt' ) ) {
 -                      // $wgUser->getName() only works after the end of Setup.php. Until
 -                      // then, assume it's a logged-out user.
 -                      $globalUserName = $wgUser->isSafeToLoad()
 -                              ? $wgUser->getName()
 -                              : IP::sanitizeIP( $wgUser->getRequest()->getIP() );
 -                      if ( $this->getName() === $globalUserName ) {
 -                              $ip = $this->getRequest()->getIP();
 -                      }
 +              $sessionUser = RequestContext::getMain()->getUser();
 +              // the session user is set up towards the end of Setup.php. Until then,
 +              // assume it's a logged-out user.
 +              $globalUserName = $sessionUser->isSafeToLoad()
 +                      ? $sessionUser->getName()
 +                      : IP::sanitizeIP( $sessionUser->getRequest()->getIP() );
 +              if ( $this->getName() === $globalUserName && !$this->isAllowed( 'ipblock-exempt' ) ) {
 +                      $ip = $this->getRequest()->getIP();
                }
  
                // User/IP blocking
 -              $block = Block::newFromTarget( $this, $ip, !$bFromReplica );
 +              $block = Block::newFromTarget( $this, $ip, !$fromReplica );
  
                // Cookie blocking
                if ( !$block instanceof Block ) {
                        $xff = $this->getRequest()->getHeader( 'X-Forwarded-For' );
                        $xff = array_map( 'trim', explode( ',', $xff ) );
                        $xff = array_diff( $xff, [ $ip ] );
 -                      $xffblocks = Block::getBlocksForIPList( $xff, $this->isAnon(), !$bFromReplica );
 +                      $xffblocks = Block::getBlocksForIPList( $xff, $this->isAnon(), !$fromReplica );
                        $block = Block::chooseBlock( $xffblocks, $xff );
                        if ( $block instanceof Block ) {
                                # Mangle the reason to alert the user that the block
                                # originated from matching the X-Forwarded-For header.
 -                              $block->mReason = wfMessage( 'xffblockreason', $block->mReason )->plain();
 +                              $block->setReason( wfMessage( 'xffblockreason', $block->getReason() )->plain() );
                        }
                }
  
                        wfDebug( __METHOD__ . ": Found block.\n" );
                        $this->mBlock = $block;
                        $this->mBlockedby = $block->getByName();
 -                      $this->mBlockreason = $block->mReason;
 -                      $this->mHideName = $block->mHideName;
 +                      $this->mBlockreason = $block->getReason();
 +                      $this->mHideName = $block->getHideName();
                        $this->mAllowUsertalk = $block->isUsertalkEditAllowed();
                } else {
                        $this->mBlock = null;
                }
  
                // Avoid PHP 7.1 warning of passing $this by reference
 -              $user = $this;
 +              $thisUser = $this;
                // Extensions
 -              Hooks::run( 'GetBlockedStatus', [ &$user ] );
 +              Hooks::run( 'GetBlockedStatus', [ &$thisUser ] );
        }
  
        /**
  
                // Set the user limit key
                if ( $userLimit !== false ) {
 +                      // phan is confused because &can-bypass's value is a bool, so it assumes
 +                      // that $userLimit is also a bool here.
 +                      // @phan-suppress-next-line PhanTypeInvalidExpressionArrayDestructuring
                        list( $max, $period ) = $userLimit;
                        wfDebug( __METHOD__ . ": effective user limit: $max in {$period}s\n" );
                        $keys[$cache->makeKey( 'limiter', $action, 'user', $id )] = $userLimit;
  
                $triggered = false;
                foreach ( $keys as $key => $limit ) {
 +                      // phan is confused because &can-bypass's value is a bool, so it assumes
 +                      // that $userLimit is also a bool here.
 +                      // @phan-suppress-next-line PhanTypeInvalidExpressionArrayDestructuring
                        list( $max, $period ) = $limit;
                        $summary = "(limit $max in {$period}s)";
                        $count = $cache->get( $key );
        /**
         * Check if user is blocked
         *
 -       * @param bool $bFromReplica Whether to check the replica DB instead of
 +       * @param bool $fromReplica Whether to check the replica DB instead of
         *   the master. Hacked from false due to horrible probs on site.
         * @return bool True if blocked, false otherwise
         */
 -      public function isBlocked( $bFromReplica = true ) {
 -              return $this->getBlock( $bFromReplica ) instanceof Block &&
 +      public function isBlocked( $fromReplica = true ) {
 +              return $this->getBlock( $fromReplica ) instanceof Block &&
                        $this->getBlock()->appliesToRight( 'edit' );
        }
  
        /**
         * Get the block affecting the user, or null if the user is not blocked
         *
 -       * @param bool $bFromReplica Whether to check the replica DB instead of the master
 +       * @param bool $fromReplica Whether to check the replica DB instead of the master
         * @return Block|null
         */
 -      public function getBlock( $bFromReplica = true ) {
 -              $this->getBlockedStatus( $bFromReplica );
 +      public function getBlock( $fromReplica = true ) {
 +              $this->getBlockedStatus( $fromReplica );
                return $this->mBlock instanceof Block ? $this->mBlock : null;
        }
  
         * @param Title $title Title to check
         * @param bool $fromReplica Whether to check the replica DB instead of the master
         * @return bool
 +       * @throws MWException
 +       *
 +       * @deprecated since 1.33,
 +       * use MediaWikiServices::getInstance()->getPermissionManager()->isBlockedFrom(..)
 +       *
         */
        public function isBlockedFrom( $title, $fromReplica = false ) {
 -              $blocked = $this->isHidden();
 -
 -              if ( !$blocked ) {
 -                      $block = $this->getBlock( $fromReplica );
 -                      if ( $block ) {
 -                              // Special handling for a user's own talk page. The block is not aware
 -                              // of the user, so this must be done here.
 -                              if ( $title->equals( $this->getTalkPage() ) ) {
 -                                      $blocked = $block->appliesToUsertalk( $title );
 -                              } else {
 -                                      $blocked = $block->appliesToTitle( $title );
 -                              }
 -                      }
 -              }
 -
 -              // only for the purpose of the hook. We really don't need this here.
 -              $allowUsertalk = $this->mAllowUsertalk;
 -
 -              Hooks::run( 'UserIsBlockedFrom', [ $this, $title, &$blocked, &$allowUsertalk ] );
 -
 -              return $blocked;
 +              return MediaWikiServices::getInstance()->getPermissionManager()
 +                      ->isBlockedFrom( $this, $title, $fromReplica );
        }
  
        /**
                if ( $this->mLocked !== null ) {
                        return $this->mLocked;
                }
 -              // Avoid PHP 7.1 warning of passing $this by reference
 -              $user = $this;
 -              $authUser = AuthManager::callLegacyAuthPlugin( 'getUserInstance', [ &$user ], null );
 -              $this->mLocked = $authUser && $authUser->isLocked();
 +              // Reset for hook
 +              $this->mLocked = false;
                Hooks::run( 'UserIsLocked', [ $this, &$this->mLocked ] );
                return $this->mLocked;
        }
                }
                $this->getBlockedStatus();
                if ( !$this->mHideName ) {
 -                      // Avoid PHP 7.1 warning of passing $this by reference
 -                      $user = $this;
 -                      $authUser = AuthManager::callLegacyAuthPlugin( 'getUserInstance', [ &$user ], null );
 -                      $this->mHideName = $authUser && $authUser->isHidden();
 +                      // Reset for hook
 +                      $this->mHideName = false;
                        Hooks::run( 'UserIsHidden', [ $this, &$this->mHideName ] );
                }
                return (bool)$this->mHideName;
        /**
         * Generate a current or new-future timestamp to be stored in the
         * user_touched field when we update things.
 +       *
         * @return string Timestamp in TS_MW format
         */
        private function newTouchedTimestamp() {
 -              global $wgClockSkewFudge;
 -
 -              $time = wfTimestamp( TS_MW, time() + $wgClockSkewFudge );
 -              if ( $this->mTouched && $time <= $this->mTouched ) {
 -                      $time = wfTimestamp( TS_MW, wfTimestamp( TS_UNIX, $this->mTouched ) + 1 );
 +              $time = time();
 +              if ( $this->mTouched ) {
 +                      $time = max( $time, wfTimestamp( TS_UNIX, $this->mTouched ) + 1 );
                }
  
 -              return $time;
 +              return wfTimestamp( TS_MW, $time );
        }
  
        /**
  
                Hooks::run( 'UserSaveSettings', [ $this ] );
                $this->clearSharedCache();
 -              $this->getUserPage()->invalidateCache();
 +              $this->getUserPage()->purgeSquid();
        }
  
        /**
                                        [ 'LOCK IN SHARE MODE' ]
                                );
                                $loaded = false;
 -                              if ( $this->mId ) {
 -                                      if ( $this->loadFromDatabase( self::READ_LOCKING ) ) {
 -                                              $loaded = true;
 -                                      }
 +                              if ( $this->mId && $this->loadFromDatabase( self::READ_LOCKING ) ) {
 +                                      $loaded = true;
                                }
                                if ( !$loaded ) {
                                        throw new MWException( $fname . ": hit a key conflict attempting " .
  
                if ( $type == 'created' || $type === false ) {
                        $message = 'confirmemail_body';
 +                      $type = 'created';
                } elseif ( $type === true ) {
                        $message = 'confirmemail_body_changed';
 +                      $type = 'changed';
                } else {
                        // Messages: confirmemail_body_changed, confirmemail_body_set
                        $message = 'confirmemail_body_' . $type;
                }
  
 -              return $this->sendMail( wfMessage( 'confirmemail_subject' )->text(),
 -                      wfMessage( $message,
 +              $mail = [
 +                      'subject' => wfMessage( 'confirmemail_subject' )->text(),
 +                      'body' => wfMessage( $message,
                                $this->getRequest()->getIP(),
                                $this->getName(),
                                $url,
                                $wgLang->userTimeAndDate( $expiration, $this ),
                                $invalidateURL,
                                $wgLang->userDate( $expiration, $this ),
 -                              $wgLang->userTime( $expiration, $this ) )->text() );
 +                              $wgLang->userTime( $expiration, $this ) )->text(),
 +                      'from' => null,
 +                      'replyTo' => null,
 +              ];
 +              $info = [
 +                      'type' => $type,
 +                      'ip' => $this->getRequest()->getIP(),
 +                      'confirmURL' => $url,
 +                      'invalidateURL' => $invalidateURL,
 +                      'expiration' => $expiration
 +              ];
 +
 +              Hooks::run( 'UserSendConfirmationMail', [ $this, &$mail, $info ] );
 +              return $this->sendMail( $mail['subject'], $mail['body'], $mail['from'], $mail['replyTo'] );
        }
  
        /**
         * @param string $body Message body
         * @param User|null $from Optional sending user; if unspecified, default
         *   $wgPasswordSender will be used.
 -       * @param string|null $replyto Reply-To address
 +       * @param MailAddress|null $replyto Reply-To address
         * @return Status
         */
        public function sendMail( $subject, $body, $from = null, $replyto = null ) {
         *  non-existent/anonymous user accounts.
         */
        public function getFirstEditTimestamp() {
 +              return $this->getEditTimestamp( true );
 +      }
 +
 +      /**
 +       * Get the timestamp of the latest edit
 +       *
 +       * @since 1.33
 +       * @return string|bool Timestamp of first edit, or false for
 +       *  non-existent/anonymous user accounts.
 +       */
 +      public function getLatestEditTimestamp() {
 +              return $this->getEditTimestamp( false );
 +      }
 +
 +      /**
 +       * Get the timestamp of the first or latest edit
 +       *
 +       * @param bool $first True for the first edit, false for the latest one
 +       * @return string|bool Timestamp of first or latest edit, or false for
 +       *  non-existent/anonymous user accounts.
 +       */
 +      private function getEditTimestamp( $first ) {
                if ( $this->getId() == 0 ) {
                        return false; // anons
                }
                $actorWhere = ActorMigration::newMigration()->getWhere( $dbr, 'rev_user', $this );
                $tsField = isset( $actorWhere['tables']['temp_rev_user'] )
                        ? 'revactor_timestamp' : 'rev_timestamp';
 +              $sortOrder = $first ? 'ASC' : 'DESC';
                $time = $dbr->selectField(
                        [ 'revision' ] + $actorWhere['tables'],
                        $tsField,
                        [ $actorWhere['conds'] ],
                        __METHOD__,
 -                      [ 'ORDER BY' => "$tsField ASC" ],
 +                      [ 'ORDER BY' => "$tsField $sortOrder" ],
                        $actorWhere['joins']
                );
                if ( !$time ) {
                // XXX it's not clear whether central ID providers are supposed to obey this
                return $this->getName() === $user->getName();
        }
 +
 +      /**
 +       * Checks if usertalk is allowed
 +       *
 +       * @return bool
 +       */
 +      public function isAllowUsertalk() {
 +              return $this->mAllowUsertalk;
 +      }
 +
  }
@@@ -32,9 -32,6 +32,6 @@@ class NoWriteWatchedItemStore implement
         */
        private $actualStore;
  
-       /**
-        * @var string
-        */
        const DB_READONLY_ERROR = 'The watchlist is currently readonly.';
  
        /**
                throw new DBReadOnlyError( null, self::DB_READONLY_ERROR );
        }
  
 +      public function getLatestNotificationTimestamp( $timestamp, User $user, LinkTarget $target ) {
 +              return wfTimestampOrNull( TS_MW, $timestamp );
 +      }
  }