From: Reedy Date: Sun, 14 Apr 2019 12:32:59 +0000 (+0100) Subject: Fix casing of Special Pages to match class name X-Git-Tag: 1.34.0-rc.0~1988^2 X-Git-Url: https://git.heureux-cyclage.org/?p=lhc%2Fweb%2Fwiklou.git;a=commitdiff_plain;h=c28707d371d51e1f0e23e6dec33c603512096ec0 Fix casing of Special Pages to match class name Change-Id: Ifc9e827202493e8f055a21875c54ff827a38d1f7 --- diff --git a/.phpcs.xml b/.phpcs.xml index 31ee7068b2..37004716b7 100644 --- a/.phpcs.xml +++ b/.phpcs.xml @@ -170,17 +170,6 @@ Whitelist existing violations, but enable the sniff to prevent any new occurrences. --> - */includes/specials/SpecialActiveusers\.php - */includes/specials/SpecialBooksources\.php - */includes/specials/SpecialEmailuser\.php - */includes/specials/SpecialListfiles\.php - */includes/specials/SpecialListgrants\.php - */includes/specials/SpecialListgrouprights\.php - */includes/specials/SpecialListusers.php - */includes/specials/SpecialRecentchanges\.php - */includes/specials/SpecialRecentchangeslinked\.php - */includes/specials/SpecialRevisiondelete\.php - */includes/specials/SpecialWhatlinkshere\.php */maintenance/language/alltrans\.php */maintenance/language/digit2html\.php */maintenance/language/langmemusage\.php diff --git a/autoload.php b/autoload.php index 5ed3981cc8..00b9ff2317 100644 --- a/autoload.php +++ b/autoload.php @@ -1343,7 +1343,7 @@ $wgAutoloadLocalClasses = [ 'SkinTemplate' => __DIR__ . '/includes/skins/SkinTemplate.php', 'SlideshowImageGallery' => __DIR__ . '/includes/gallery/SlideshowImageGallery.php', 'SlotDiffRenderer' => __DIR__ . '/includes/diff/SlotDiffRenderer.php', - 'SpecialActiveUsers' => __DIR__ . '/includes/specials/SpecialActiveusers.php', + 'SpecialActiveUsers' => __DIR__ . '/includes/specials/SpecialActiveUsers.php', 'SpecialAllMessages' => __DIR__ . '/includes/specials/SpecialAllMessages.php', 'SpecialAllMyUploads' => __DIR__ . '/includes/specials/redirects/SpecialAllMyUploads.php', 'SpecialAllPages' => __DIR__ . '/includes/specials/SpecialAllPages.php', @@ -1353,7 +1353,7 @@ $wgAutoloadLocalClasses = [ 'SpecialBlankpage' => __DIR__ . '/includes/specials/SpecialBlankpage.php', 'SpecialBlock' => __DIR__ . '/includes/specials/SpecialBlock.php', 'SpecialBlockList' => __DIR__ . '/includes/specials/SpecialBlockList.php', - 'SpecialBookSources' => __DIR__ . '/includes/specials/SpecialBooksources.php', + 'SpecialBookSources' => __DIR__ . '/includes/specials/SpecialBookSources.php', 'SpecialBotPasswords' => __DIR__ . '/includes/specials/SpecialBotPasswords.php', 'SpecialCachedPage' => __DIR__ . '/includes/specials/SpecialCachedPage.php', 'SpecialCategories' => __DIR__ . '/includes/specials/SpecialCategories.php', @@ -1367,7 +1367,7 @@ $wgAutoloadLocalClasses = [ 'SpecialDiff' => __DIR__ . '/includes/specials/SpecialDiff.php', 'SpecialEditTags' => __DIR__ . '/includes/specials/SpecialEditTags.php', 'SpecialEditWatchlist' => __DIR__ . '/includes/specials/SpecialEditWatchlist.php', - 'SpecialEmailUser' => __DIR__ . '/includes/specials/SpecialEmailuser.php', + 'SpecialEmailUser' => __DIR__ . '/includes/specials/SpecialEmailUser.php', 'SpecialExpandTemplates' => __DIR__ . '/includes/specials/SpecialExpandTemplates.php', 'SpecialExport' => __DIR__ . '/includes/specials/SpecialExport.php', 'SpecialFilepath' => __DIR__ . '/includes/specials/SpecialFilepath.php', @@ -1377,10 +1377,10 @@ $wgAutoloadLocalClasses = [ 'SpecialLinkAccounts' => __DIR__ . '/includes/specials/SpecialLinkAccounts.php', 'SpecialListAdmins' => __DIR__ . '/includes/specials/redirects/SpecialListAdmins.php', 'SpecialListBots' => __DIR__ . '/includes/specials/redirects/SpecialListBots.php', - 'SpecialListFiles' => __DIR__ . '/includes/specials/SpecialListfiles.php', - 'SpecialListGrants' => __DIR__ . '/includes/specials/SpecialListgrants.php', - 'SpecialListGroupRights' => __DIR__ . '/includes/specials/SpecialListgrouprights.php', - 'SpecialListUsers' => __DIR__ . '/includes/specials/SpecialListusers.php', + 'SpecialListFiles' => __DIR__ . '/includes/specials/SpecialListFiles.php', + 'SpecialListGrants' => __DIR__ . '/includes/specials/SpecialListGrants.php', + 'SpecialListGroupRights' => __DIR__ . '/includes/specials/SpecialListGroupRights.php', + 'SpecialListUsers' => __DIR__ . '/includes/specials/SpecialListUsers.php', 'SpecialLockdb' => __DIR__ . '/includes/specials/SpecialLockdb.php', 'SpecialLog' => __DIR__ . '/includes/specials/SpecialLog.php', 'SpecialMergeHistory' => __DIR__ . '/includes/specials/SpecialMergeHistory.php', @@ -1407,13 +1407,13 @@ $wgAutoloadLocalClasses = [ 'SpecialRandomInCategory' => __DIR__ . '/includes/specials/SpecialRandomInCategory.php', 'SpecialRandomredirect' => __DIR__ . '/includes/specials/SpecialRandomredirect.php', 'SpecialRandomrootpage' => __DIR__ . '/includes/specials/SpecialRandomrootpage.php', - 'SpecialRecentChanges' => __DIR__ . '/includes/specials/SpecialRecentchanges.php', - 'SpecialRecentChangesLinked' => __DIR__ . '/includes/specials/SpecialRecentchangeslinked.php', + 'SpecialRecentChanges' => __DIR__ . '/includes/specials/SpecialRecentChanges.php', + 'SpecialRecentChangesLinked' => __DIR__ . '/includes/specials/SpecialRecentChangesLinked.php', 'SpecialRedirect' => __DIR__ . '/includes/specials/SpecialRedirect.php', 'SpecialRedirectToSpecial' => __DIR__ . '/includes/specialpage/SpecialRedirectToSpecial.php', 'SpecialRemoveCredentials' => __DIR__ . '/includes/specials/SpecialRemoveCredentials.php', 'SpecialResetTokens' => __DIR__ . '/includes/specials/SpecialResetTokens.php', - 'SpecialRevisionDelete' => __DIR__ . '/includes/specials/SpecialRevisiondelete.php', + 'SpecialRevisionDelete' => __DIR__ . '/includes/specials/SpecialRevisionDelete.php', 'SpecialRunJobs' => __DIR__ . '/includes/specials/SpecialRunJobs.php', 'SpecialSearch' => __DIR__ . '/includes/specials/SpecialSearch.php', 'SpecialSpecialpages' => __DIR__ . '/includes/specials/SpecialSpecialpages.php', @@ -1431,7 +1431,7 @@ $wgAutoloadLocalClasses = [ 'SpecialUserLogout' => __DIR__ . '/includes/specials/SpecialUserLogout.php', 'SpecialVersion' => __DIR__ . '/includes/specials/SpecialVersion.php', 'SpecialWatchlist' => __DIR__ . '/includes/specials/SpecialWatchlist.php', - 'SpecialWhatLinksHere' => __DIR__ . '/includes/specials/SpecialWhatlinkshere.php', + 'SpecialWhatLinksHere' => __DIR__ . '/includes/specials/SpecialWhatLinksHere.php', 'SqlBagOStuff' => __DIR__ . '/includes/objectcache/SqlBagOStuff.php', 'SqlSearchResultSet' => __DIR__ . '/includes/search/SqlSearchResultSet.php', 'Sqlite' => __DIR__ . '/maintenance/sqlite.inc', diff --git a/includes/specials/SpecialActiveUsers.php b/includes/specials/SpecialActiveUsers.php new file mode 100644 index 0000000000..f52a6f35c6 --- /dev/null +++ b/includes/specials/SpecialActiveUsers.php @@ -0,0 +1,172 @@ +getOutput(); + + $this->setHeaders(); + $this->outputHeader(); + + $opts = new FormOptions(); + + $opts->add( 'username', '' ); + $opts->add( 'groups', [] ); + $opts->add( 'excludegroups', [] ); + // Backwards-compatibility with old URLs + $opts->add( 'hidebots', false, FormOptions::BOOL ); + $opts->add( 'hidesysops', false, FormOptions::BOOL ); + + $opts->fetchValuesFromRequest( $this->getRequest() ); + + if ( $par !== null ) { + $opts->setValue( 'username', $par ); + } + + $pager = new ActiveUsersPager( $this->getContext(), $opts ); + $usersBody = $pager->getBody(); + + $this->buildForm(); + + if ( $usersBody ) { + $out->addHTML( + $pager->getNavigationBar() . + Html::rawElement( 'ul', [], $usersBody ) . + $pager->getNavigationBar() + ); + } else { + $out->addWikiMsg( 'activeusers-noresult' ); + } + } + + /** + * Generate and output the form + */ + protected function buildForm() { + $groups = User::getAllGroups(); + + $options = []; + foreach ( $groups as $group ) { + $msg = htmlspecialchars( UserGroupMembership::getGroupName( $group ) ); + $options[$msg] = $group; + } + asort( $options ); + + // Backwards-compatibility with old URLs + $req = $this->getRequest(); + $excludeDefault = []; + if ( $req->getCheck( 'hidebots' ) ) { + $excludeDefault[] = 'bot'; + } + if ( $req->getCheck( 'hidesysops' ) ) { + $excludeDefault[] = 'sysop'; + } + + $formDescriptor = [ + 'username' => [ + 'type' => 'user', + 'name' => 'username', + 'label-message' => 'activeusers-from', + ], + 'groups' => [ + 'type' => 'multiselect', + 'dropdown' => true, + 'flatlist' => true, + 'name' => 'groups', + 'label-message' => 'activeusers-groups', + 'options' => $options, + ], + 'excludegroups' => [ + 'type' => 'multiselect', + 'dropdown' => true, + 'flatlist' => true, + 'name' => 'excludegroups', + 'label-message' => 'activeusers-excludegroups', + 'options' => $options, + 'default' => $excludeDefault, + ], + ]; + + HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) + // For the 'multiselect' field values to be preserved on submit + ->setFormIdentifier( 'specialactiveusers' ) + ->setIntro( $this->getIntroText() ) + ->setWrapperLegendMsg( 'activeusers' ) + ->setSubmitTextMsg( 'activeusers-submit' ) + // prevent setting subpage and 'username' parameter at the same time + ->setAction( $this->getPageTitle()->getLocalURL() ) + ->setMethod( 'get' ) + ->prepareForm() + ->displayForm( false ); + } + + /** + * Return introductory message. + * @return string + */ + protected function getIntroText() { + $days = $this->getConfig()->get( 'ActiveUserDays' ); + + $intro = $this->msg( 'activeusers-intro' )->numParams( $days )->parse(); + + // Mention the level of cache staleness... + $dbr = wfGetDB( DB_REPLICA, 'recentchanges' ); + $rcMax = $dbr->selectField( 'recentchanges', 'MAX(rc_timestamp)', '', __METHOD__ ); + if ( $rcMax ) { + $cTime = $dbr->selectField( 'querycache_info', + 'qci_timestamp', + [ 'qci_type' => 'activeusers' ], + __METHOD__ + ); + if ( $cTime ) { + $secondsOld = wfTimestamp( TS_UNIX, $rcMax ) - wfTimestamp( TS_UNIX, $cTime ); + } else { + $rcMin = $dbr->selectField( 'recentchanges', 'MIN(rc_timestamp)' ); + $secondsOld = time() - wfTimestamp( TS_UNIX, $rcMin ); + } + if ( $secondsOld > 0 ) { + $intro .= $this->msg( 'cachedspecial-viewing-cached-ttl' ) + ->durationParams( $secondsOld )->parseAsBlock(); + } + } + + return $intro; + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/includes/specials/SpecialActiveusers.php b/includes/specials/SpecialActiveusers.php deleted file mode 100644 index f52a6f35c6..0000000000 --- a/includes/specials/SpecialActiveusers.php +++ /dev/null @@ -1,172 +0,0 @@ -getOutput(); - - $this->setHeaders(); - $this->outputHeader(); - - $opts = new FormOptions(); - - $opts->add( 'username', '' ); - $opts->add( 'groups', [] ); - $opts->add( 'excludegroups', [] ); - // Backwards-compatibility with old URLs - $opts->add( 'hidebots', false, FormOptions::BOOL ); - $opts->add( 'hidesysops', false, FormOptions::BOOL ); - - $opts->fetchValuesFromRequest( $this->getRequest() ); - - if ( $par !== null ) { - $opts->setValue( 'username', $par ); - } - - $pager = new ActiveUsersPager( $this->getContext(), $opts ); - $usersBody = $pager->getBody(); - - $this->buildForm(); - - if ( $usersBody ) { - $out->addHTML( - $pager->getNavigationBar() . - Html::rawElement( 'ul', [], $usersBody ) . - $pager->getNavigationBar() - ); - } else { - $out->addWikiMsg( 'activeusers-noresult' ); - } - } - - /** - * Generate and output the form - */ - protected function buildForm() { - $groups = User::getAllGroups(); - - $options = []; - foreach ( $groups as $group ) { - $msg = htmlspecialchars( UserGroupMembership::getGroupName( $group ) ); - $options[$msg] = $group; - } - asort( $options ); - - // Backwards-compatibility with old URLs - $req = $this->getRequest(); - $excludeDefault = []; - if ( $req->getCheck( 'hidebots' ) ) { - $excludeDefault[] = 'bot'; - } - if ( $req->getCheck( 'hidesysops' ) ) { - $excludeDefault[] = 'sysop'; - } - - $formDescriptor = [ - 'username' => [ - 'type' => 'user', - 'name' => 'username', - 'label-message' => 'activeusers-from', - ], - 'groups' => [ - 'type' => 'multiselect', - 'dropdown' => true, - 'flatlist' => true, - 'name' => 'groups', - 'label-message' => 'activeusers-groups', - 'options' => $options, - ], - 'excludegroups' => [ - 'type' => 'multiselect', - 'dropdown' => true, - 'flatlist' => true, - 'name' => 'excludegroups', - 'label-message' => 'activeusers-excludegroups', - 'options' => $options, - 'default' => $excludeDefault, - ], - ]; - - HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) - // For the 'multiselect' field values to be preserved on submit - ->setFormIdentifier( 'specialactiveusers' ) - ->setIntro( $this->getIntroText() ) - ->setWrapperLegendMsg( 'activeusers' ) - ->setSubmitTextMsg( 'activeusers-submit' ) - // prevent setting subpage and 'username' parameter at the same time - ->setAction( $this->getPageTitle()->getLocalURL() ) - ->setMethod( 'get' ) - ->prepareForm() - ->displayForm( false ); - } - - /** - * Return introductory message. - * @return string - */ - protected function getIntroText() { - $days = $this->getConfig()->get( 'ActiveUserDays' ); - - $intro = $this->msg( 'activeusers-intro' )->numParams( $days )->parse(); - - // Mention the level of cache staleness... - $dbr = wfGetDB( DB_REPLICA, 'recentchanges' ); - $rcMax = $dbr->selectField( 'recentchanges', 'MAX(rc_timestamp)', '', __METHOD__ ); - if ( $rcMax ) { - $cTime = $dbr->selectField( 'querycache_info', - 'qci_timestamp', - [ 'qci_type' => 'activeusers' ], - __METHOD__ - ); - if ( $cTime ) { - $secondsOld = wfTimestamp( TS_UNIX, $rcMax ) - wfTimestamp( TS_UNIX, $cTime ); - } else { - $rcMin = $dbr->selectField( 'recentchanges', 'MIN(rc_timestamp)' ); - $secondsOld = time() - wfTimestamp( TS_UNIX, $rcMin ); - } - if ( $secondsOld > 0 ) { - $intro .= $this->msg( 'cachedspecial-viewing-cached-ttl' ) - ->durationParams( $secondsOld )->parseAsBlock(); - } - } - - return $intro; - } - - protected function getGroupName() { - return 'users'; - } -} diff --git a/includes/specials/SpecialBookSources.php b/includes/specials/SpecialBookSources.php new file mode 100644 index 0000000000..ea9ddafed1 --- /dev/null +++ b/includes/specials/SpecialBookSources.php @@ -0,0 +1,212 @@ + + * @ingroup SpecialPage + */ +class SpecialBookSources extends SpecialPage { + public function __construct() { + parent::__construct( 'Booksources' ); + } + + /** + * @param string|null $isbn ISBN passed as a subpage parameter + */ + public function execute( $isbn ) { + $out = $this->getOutput(); + + $this->setHeaders(); + $this->outputHeader(); + + // User provided ISBN + $isbn = $isbn ?: $this->getRequest()->getText( 'isbn' ); + $isbn = trim( $isbn ); + + $this->buildForm( $isbn ); + + if ( $isbn !== '' ) { + if ( !self::isValidISBN( $isbn ) ) { + $out->wrapWikiMsg( + "
\n$1\n
", + 'booksources-invalid-isbn' + ); + } + + $this->showList( $isbn ); + } + } + + /** + * Return whether a given ISBN (10 or 13) is valid. + * + * @param string $isbn ISBN passed for check + * @return bool + */ + public static function isValidISBN( $isbn ) { + $isbn = self::cleanIsbn( $isbn ); + $sum = 0; + if ( strlen( $isbn ) == 13 ) { + for ( $i = 0; $i < 12; $i++ ) { + if ( $isbn[$i] === 'X' ) { + return false; + } elseif ( $i % 2 == 0 ) { + $sum += $isbn[$i]; + } else { + $sum += 3 * $isbn[$i]; + } + } + + $check = ( 10 - ( $sum % 10 ) ) % 10; + if ( (string)$check === $isbn[12] ) { + return true; + } + } elseif ( strlen( $isbn ) == 10 ) { + for ( $i = 0; $i < 9; $i++ ) { + if ( $isbn[$i] === 'X' ) { + return false; + } + $sum += $isbn[$i] * ( $i + 1 ); + } + + $check = $sum % 11; + if ( $check == 10 ) { + $check = "X"; + } + if ( (string)$check === $isbn[9] ) { + return true; + } + } + + return false; + } + + /** + * Trim ISBN and remove characters which aren't required + * + * @param string $isbn Unclean ISBN + * @return string + */ + private static function cleanIsbn( $isbn ) { + return trim( preg_replace( '![^0-9X]!', '', $isbn ) ); + } + + /** + * Generate a form to allow users to enter an ISBN + * + * @param string $isbn + */ + private function buildForm( $isbn ) { + $formDescriptor = [ + 'isbn' => [ + 'type' => 'text', + 'name' => 'isbn', + 'label-message' => 'booksources-isbn', + 'default' => $isbn, + 'autofocus' => true, + 'required' => true, + ], + ]; + + $context = new DerivativeContext( $this->getContext() ); + $context->setTitle( $this->getPageTitle() ); + HTMLForm::factory( 'ooui', $formDescriptor, $context ) + ->setWrapperLegendMsg( 'booksources-search-legend' ) + ->setSubmitTextMsg( 'booksources-search' ) + ->setMethod( 'get' ) + ->prepareForm() + ->displayForm( false ); + } + + /** + * Determine where to get the list of book sources from, + * format and output them + * + * @param string $isbn + * @throws MWException + * @return bool + */ + private function showList( $isbn ) { + $out = $this->getOutput(); + + $isbn = self::cleanIsbn( $isbn ); + # Hook to allow extensions to insert additional HTML, + # e.g. for API-interacting plugins and so on + Hooks::run( 'BookInformation', [ $isbn, $out ] ); + + # Check for a local page such as Project:Book_sources and use that if available + $page = $this->msg( 'booksources' )->inContentLanguage()->text(); + $title = Title::makeTitleSafe( NS_PROJECT, $page ); # Show list in content language + if ( is_object( $title ) && $title->exists() ) { + $rev = Revision::newFromTitle( $title, false, Revision::READ_NORMAL ); + $content = $rev->getContent(); + + if ( $content instanceof TextContent ) { + // XXX: in the future, this could be stored as structured data, defining a list of book sources + + $text = $content->getText(); + $out->addWikiTextAsInterface( str_replace( 'MAGICNUMBER', $isbn, $text ) ); + + return true; + } else { + throw new MWException( "Unexpected content type for book sources: " . $content->getModel() ); + } + } + + # Fall back to the defaults given in the language file + $out->addWikiMsg( 'booksources-text' ); + $out->addHTML( '' ); + + return true; + } + + /** + * Format a book source list item + * + * @param string $isbn + * @param string $label Book source label + * @param string $url Book source URL + * @return string + */ + private function makeListItem( $isbn, $label, $url ) { + $url = str_replace( '$1', $isbn, $url ); + + return Html::rawElement( 'li', [], + Html::element( 'a', [ 'href' => $url, 'class' => 'external' ], $label ) + ); + } + + protected function getGroupName() { + return 'wiki'; + } +} diff --git a/includes/specials/SpecialBooksources.php b/includes/specials/SpecialBooksources.php deleted file mode 100644 index ea9ddafed1..0000000000 --- a/includes/specials/SpecialBooksources.php +++ /dev/null @@ -1,212 +0,0 @@ - - * @ingroup SpecialPage - */ -class SpecialBookSources extends SpecialPage { - public function __construct() { - parent::__construct( 'Booksources' ); - } - - /** - * @param string|null $isbn ISBN passed as a subpage parameter - */ - public function execute( $isbn ) { - $out = $this->getOutput(); - - $this->setHeaders(); - $this->outputHeader(); - - // User provided ISBN - $isbn = $isbn ?: $this->getRequest()->getText( 'isbn' ); - $isbn = trim( $isbn ); - - $this->buildForm( $isbn ); - - if ( $isbn !== '' ) { - if ( !self::isValidISBN( $isbn ) ) { - $out->wrapWikiMsg( - "
\n$1\n
", - 'booksources-invalid-isbn' - ); - } - - $this->showList( $isbn ); - } - } - - /** - * Return whether a given ISBN (10 or 13) is valid. - * - * @param string $isbn ISBN passed for check - * @return bool - */ - public static function isValidISBN( $isbn ) { - $isbn = self::cleanIsbn( $isbn ); - $sum = 0; - if ( strlen( $isbn ) == 13 ) { - for ( $i = 0; $i < 12; $i++ ) { - if ( $isbn[$i] === 'X' ) { - return false; - } elseif ( $i % 2 == 0 ) { - $sum += $isbn[$i]; - } else { - $sum += 3 * $isbn[$i]; - } - } - - $check = ( 10 - ( $sum % 10 ) ) % 10; - if ( (string)$check === $isbn[12] ) { - return true; - } - } elseif ( strlen( $isbn ) == 10 ) { - for ( $i = 0; $i < 9; $i++ ) { - if ( $isbn[$i] === 'X' ) { - return false; - } - $sum += $isbn[$i] * ( $i + 1 ); - } - - $check = $sum % 11; - if ( $check == 10 ) { - $check = "X"; - } - if ( (string)$check === $isbn[9] ) { - return true; - } - } - - return false; - } - - /** - * Trim ISBN and remove characters which aren't required - * - * @param string $isbn Unclean ISBN - * @return string - */ - private static function cleanIsbn( $isbn ) { - return trim( preg_replace( '![^0-9X]!', '', $isbn ) ); - } - - /** - * Generate a form to allow users to enter an ISBN - * - * @param string $isbn - */ - private function buildForm( $isbn ) { - $formDescriptor = [ - 'isbn' => [ - 'type' => 'text', - 'name' => 'isbn', - 'label-message' => 'booksources-isbn', - 'default' => $isbn, - 'autofocus' => true, - 'required' => true, - ], - ]; - - $context = new DerivativeContext( $this->getContext() ); - $context->setTitle( $this->getPageTitle() ); - HTMLForm::factory( 'ooui', $formDescriptor, $context ) - ->setWrapperLegendMsg( 'booksources-search-legend' ) - ->setSubmitTextMsg( 'booksources-search' ) - ->setMethod( 'get' ) - ->prepareForm() - ->displayForm( false ); - } - - /** - * Determine where to get the list of book sources from, - * format and output them - * - * @param string $isbn - * @throws MWException - * @return bool - */ - private function showList( $isbn ) { - $out = $this->getOutput(); - - $isbn = self::cleanIsbn( $isbn ); - # Hook to allow extensions to insert additional HTML, - # e.g. for API-interacting plugins and so on - Hooks::run( 'BookInformation', [ $isbn, $out ] ); - - # Check for a local page such as Project:Book_sources and use that if available - $page = $this->msg( 'booksources' )->inContentLanguage()->text(); - $title = Title::makeTitleSafe( NS_PROJECT, $page ); # Show list in content language - if ( is_object( $title ) && $title->exists() ) { - $rev = Revision::newFromTitle( $title, false, Revision::READ_NORMAL ); - $content = $rev->getContent(); - - if ( $content instanceof TextContent ) { - // XXX: in the future, this could be stored as structured data, defining a list of book sources - - $text = $content->getText(); - $out->addWikiTextAsInterface( str_replace( 'MAGICNUMBER', $isbn, $text ) ); - - return true; - } else { - throw new MWException( "Unexpected content type for book sources: " . $content->getModel() ); - } - } - - # Fall back to the defaults given in the language file - $out->addWikiMsg( 'booksources-text' ); - $out->addHTML( '' ); - - return true; - } - - /** - * Format a book source list item - * - * @param string $isbn - * @param string $label Book source label - * @param string $url Book source URL - * @return string - */ - private function makeListItem( $isbn, $label, $url ) { - $url = str_replace( '$1', $isbn, $url ); - - return Html::rawElement( 'li', [], - Html::element( 'a', [ 'href' => $url, 'class' => 'external' ], $label ) - ); - } - - protected function getGroupName() { - return 'wiki'; - } -} diff --git a/includes/specials/SpecialEmailUser.php b/includes/specials/SpecialEmailUser.php new file mode 100644 index 0000000000..5f80215632 --- /dev/null +++ b/includes/specials/SpecialEmailUser.php @@ -0,0 +1,533 @@ +mTarget, $this->getUser() ); + if ( !$target instanceof User ) { + return $this->msg( 'emailuser-title-notarget' )->text(); + } + + return $this->msg( 'emailuser-title-target', $target->getName() )->text(); + } + + protected function getFormFields() { + $linkRenderer = $this->getLinkRenderer(); + return [ + 'From' => [ + 'type' => 'info', + 'raw' => 1, + 'default' => $linkRenderer->makeLink( + $this->getUser()->getUserPage(), + $this->getUser()->getName() + ), + 'label-message' => 'emailfrom', + 'id' => 'mw-emailuser-sender', + ], + 'To' => [ + 'type' => 'info', + 'raw' => 1, + 'default' => $linkRenderer->makeLink( + $this->mTargetObj->getUserPage(), + $this->mTargetObj->getName() + ), + 'label-message' => 'emailto', + 'id' => 'mw-emailuser-recipient', + ], + 'Target' => [ + 'type' => 'hidden', + 'default' => $this->mTargetObj->getName(), + ], + 'Subject' => [ + 'type' => 'text', + 'default' => $this->msg( 'defemailsubject', + $this->getUser()->getName() )->inContentLanguage()->text(), + 'label-message' => 'emailsubject', + 'maxlength' => 200, + 'size' => 60, + 'required' => true, + ], + 'Text' => [ + 'type' => 'textarea', + 'rows' => 20, + 'label-message' => 'emailmessage', + 'required' => true, + ], + 'CCMe' => [ + 'type' => 'check', + 'label-message' => 'emailccme', + 'default' => $this->getUser()->getBoolOption( 'ccmeonemails' ), + ], + ]; + } + + public function execute( $par ) { + $out = $this->getOutput(); + $request = $this->getRequest(); + $out->addModuleStyles( 'mediawiki.special' ); + + $this->mTarget = $par ?? $request->getVal( 'wpTarget', $request->getVal( 'target', '' ) ); + + // Make sure, that HTMLForm uses the correct target. + $request->setVal( 'wpTarget', $this->mTarget ); + + // This needs to be below assignment of $this->mTarget because + // getDescription() needs it to determine the correct page title. + $this->setHeaders(); + $this->outputHeader(); + + // error out if sending user cannot do this + $error = self::getPermissionsError( + $this->getUser(), + $this->getRequest()->getVal( 'wpEditToken' ), + $this->getConfig() + ); + + switch ( $error ) { + case null: + # Wahey! + break; + case 'badaccess': + throw new PermissionsError( 'sendemail' ); + case 'blockedemailuser': + throw $this->getBlockedEmailError(); + case 'actionthrottledtext': + throw new ThrottledError; + case 'mailnologin': + case 'usermaildisabled': + throw new ErrorPageError( $error, "{$error}text" ); + default: + # It's a hook error + list( $title, $msg, $params ) = $error; + throw new ErrorPageError( $title, $msg, $params ); + } + + // Make sure, that a submitted form isn't submitted to a subpage (which could be + // a non-existing username) + $context = new DerivativeContext( $this->getContext() ); + $context->setTitle( $this->getPageTitle() ); // Remove subpage + $this->setContext( $context ); + + // A little hack: HTMLForm will check $this->mTarget only, if the form was posted, not + // if the user opens Special:EmailUser/Florian (e.g.). So check, if the user did that + // and show the "Send email to user" form directly, if so. Show the "enter username" + // form, otherwise. + $this->mTargetObj = self::getTarget( $this->mTarget, $this->getUser() ); + if ( !$this->mTargetObj instanceof User ) { + $this->userForm( $this->mTarget ); + } else { + $this->sendEmailForm(); + } + } + + /** + * Validate target User + * + * @param string $target Target user name + * @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, User $sender = null ) { + if ( $sender === null ) { + wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' ); + } + + if ( $target == '' ) { + wfDebug( "Target is empty.\n" ); + + return 'notarget'; + } + + $nu = User::newFromName( $target ); + $error = self::validateTarget( $nu, $sender ); + + return $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'; + } + + if ( !$target->isEmailConfirmed() ) { + wfDebug( "User has no valid email.\n" ); + + return 'noemail'; + } + + if ( !$target->canReceiveEmail() ) { + wfDebug( "User does not allow user emails.\n" ); + + return 'nowikiemail'; + } + + if ( $sender !== null && !$target->getOption( 'email-allow-new-users' ) && + $sender->isNewbie() + ) { + wfDebug( "User does not allow user emails from new users.\n" ); + + return 'nowikiemail'; + } + + if ( $sender !== null ) { + $blacklist = $target->getOption( 'email-blacklist', '' ); + if ( $blacklist ) { + $blacklist = MultiUsernameFilter::splitIds( $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 ''; + } + + /** + * Check whether a user is allowed to send email + * + * @param User $user + * @param string $editToken Edit token + * @param Config|null $config optional for backwards compatibility + * @return null|string|array Null on success, string on error, or array on + * hook error + */ + public static function getPermissionsError( $user, $editToken, Config $config = null ) { + if ( $config === null ) { + wfDebug( __METHOD__ . ' called without a Config instance passed to it' ); + $config = MediaWikiServices::getInstance()->getMainConfig(); + } + if ( !$config->get( 'EnableEmail' ) || !$config->get( 'EnableUserEmail' ) ) { + return 'usermaildisabled'; + } + + // Run this before $user->isAllowed, to show appropriate message to anons (T160309) + if ( !$user->isEmailConfirmed() ) { + return 'mailnologin'; + } + + if ( !$user->isAllowed( 'sendemail' ) ) { + return 'badaccess'; + } + + if ( $user->isBlockedFromEmailuser() ) { + wfDebug( "User is blocked from sending e-mail.\n" ); + + return "blockedemailuser"; + } + + // Check the ping limiter without incrementing it - we'll check it + // again later and increment it on a successful send + if ( $user->pingLimiter( 'emailuser', 0 ) ) { + wfDebug( "Ping limiter triggered.\n" ); + + return 'actionthrottledtext'; + } + + $hookErr = false; + + Hooks::run( 'UserCanSendEmail', [ &$user, &$hookErr ] ); + Hooks::run( 'EmailUserPermissionsErrors', [ $user, $editToken, &$hookErr ] ); + + if ( $hookErr ) { + return $hookErr; + } + + return null; + } + + /** + * Form to ask for target user name. + * + * @param string $name User name submitted. + */ + protected function userForm( $name ) { + $htmlForm = HTMLForm::factory( 'ooui', [ + 'Target' => [ + 'type' => 'user', + 'exists' => true, + 'label' => $this->msg( 'emailusername' )->text(), + 'id' => 'emailusertarget', + 'autofocus' => true, + 'value' => $name, + ] + ], $this->getContext() ); + + $htmlForm + ->setMethod( 'post' ) + ->setSubmitCallback( [ $this, 'sendEmailForm' ] ) + ->setFormIdentifier( 'userForm' ) + ->setId( 'askusername' ) + ->setWrapperLegendMsg( 'emailtarget' ) + ->setSubmitTextMsg( 'emailusernamesubmit' ) + ->show(); + } + + public function sendEmailForm() { + $out = $this->getOutput(); + + $ret = $this->mTargetObj; + if ( !$ret instanceof User ) { + if ( $this->mTarget != '' ) { + // Messages used here: notargettext, noemailtext, nowikiemailtext + $ret = ( $ret == 'notarget' ) ? 'emailnotarget' : ( $ret . 'text' ); + return Status::newFatal( $ret ); + } + return false; + } + + $htmlForm = HTMLForm::factory( 'ooui', $this->getFormFields(), $this->getContext() ); + // By now we are supposed to be sure that $this->mTarget is a user name + $htmlForm + ->addPreText( $this->msg( 'emailpagetext', $this->mTarget )->parse() ) + ->setSubmitTextMsg( 'emailsend' ) + ->setSubmitCallback( [ __CLASS__, 'submit' ] ) + ->setFormIdentifier( 'sendEmailForm' ) + ->setWrapperLegendMsg( 'email-legend' ) + ->loadData(); + + if ( !Hooks::run( 'EmailUserForm', [ &$htmlForm ] ) ) { + return false; + } + + $result = $htmlForm->show(); + + if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) { + $out->setPageTitle( $this->msg( 'emailsent' ) ); + $out->addWikiMsg( 'emailsenttext', $this->mTarget ); + $out->returnToMain( false, $ret->getUserPage() ); + } + return true; + } + + /** + * Really send a mail. Permissions should have been checked using + * getPermissionsError(). It is probably also a good + * idea to check the edit token and ping limiter in advance. + * + * @param array $data + * @param IContextSource $context + * @return Status|bool + */ + public static function submit( array $data, IContextSource $context ) { + $config = $context->getConfig(); + + $target = self::getTarget( $data['Target'], $context->getUser() ); + if ( !$target instanceof User ) { + // Messages used here: notargettext, noemailtext, nowikiemailtext + return Status::newFatal( $target . 'text' ); + } + + $to = MailAddress::newFromUser( $target ); + $from = MailAddress::newFromUser( $context->getUser() ); + $subject = $data['Subject']; + $text = $data['Text']; + + // Add a standard footer and trim up trailing newlines + $text = rtrim( $text ) . "\n\n-- \n"; + $text .= $context->msg( 'emailuserfooter', + $from->name, $to->name )->inContentLanguage()->text(); + + // Check and increment the rate limits + if ( $context->getUser()->pingLimiter( 'emailuser' ) ) { + throw new ThrottledError(); + } + + $error = false; + if ( !Hooks::run( 'EmailUser', [ &$to, &$from, &$subject, &$text, &$error ] ) ) { + if ( $error instanceof Status ) { + return $error; + } elseif ( $error === false || $error === '' || $error === [] ) { + // Possibly to tell HTMLForm to pretend there was no submission? + return false; + } elseif ( $error === true ) { + // Hook sent the mail itself and indicates success? + return Status::newGood(); + } elseif ( is_array( $error ) ) { + $status = Status::newGood(); + foreach ( $error as $e ) { + $status->fatal( $e ); + } + return $status; + } elseif ( $error instanceof MessageSpecifier ) { + return Status::newFatal( $error ); + } else { + // Ugh. Either a raw HTML string, or something that's supposed + // to be treated like one. + $type = is_object( $error ) ? get_class( $error ) : gettype( $error ); + wfDeprecated( "EmailUser hook returning a $type as \$error", '1.29' ); + return Status::newFatal( new ApiRawMessage( + [ '$1', Message::rawParam( (string)$error ) ], 'hookaborted' + ) ); + } + } + + if ( $config->get( 'UserEmailUseReplyTo' ) ) { + /** + * Put the generic wiki autogenerated address in the From: + * header and reserve the user for Reply-To. + * + * This is a bit ugly, but will serve to differentiate + * wiki-borne mails from direct mails and protects against + * SPF and bounce problems with some mailers (see below). + */ + $mailFrom = new MailAddress( $config->get( 'PasswordSender' ), + $context->msg( 'emailsender' )->inContentLanguage()->text() ); + $replyTo = $from; + } else { + /** + * Put the sending user's e-mail address in the From: header. + * + * This is clean-looking and convenient, but has issues. + * One is that it doesn't as clearly differentiate the wiki mail + * from "directly" sent mails. + * + * Another is that some mailers (like sSMTP) will use the From + * address as the envelope sender as well. For open sites this + * can cause mails to be flunked for SPF violations (since the + * wiki server isn't an authorized sender for various users' + * domains) as well as creating a privacy issue as bounces + * containing the recipient's e-mail address may get sent to + * the sending user. + */ + $mailFrom = $from; + $replyTo = null; + } + + $status = UserMailer::send( $to, $mailFrom, $subject, $text, [ + 'replyTo' => $replyTo, + ] ); + + if ( !$status->isGood() ) { + return $status; + } else { + // if the user requested a copy of this mail, do this now, + // unless they are emailing themselves, in which case one + // copy of the message is sufficient. + if ( $data['CCMe'] && $to != $from ) { + $ccTo = $from; + $ccFrom = $from; + $ccSubject = $context->msg( 'emailccsubject' )->plaintextParams( + $target->getName(), $subject )->text(); + $ccText = $text; + + Hooks::run( 'EmailUserCC', [ &$ccTo, &$ccFrom, &$ccSubject, &$ccText ] ); + + if ( $config->get( 'UserEmailUseReplyTo' ) ) { + $mailFrom = new MailAddress( + $config->get( 'PasswordSender' ), + $context->msg( 'emailsender' )->inContentLanguage()->text() + ); + $replyTo = $ccFrom; + } else { + $mailFrom = $ccFrom; + $replyTo = null; + } + + $ccStatus = UserMailer::send( + $ccTo, $mailFrom, $ccSubject, $ccText, [ + 'replyTo' => $replyTo, + ] ); + $status->merge( $ccStatus ); + } + + Hooks::run( 'EmailUserComplete', [ $to, $from, $subject, $text ] ); + + return $status; + } + } + + /** + * Return an array of subpages beginning with $search that this special page will accept. + * + * @param string $search Prefix to search for + * @param int $limit Maximum number of results to return (usually 10) + * @param int $offset Number of results to skip (usually 0) + * @return string[] Matching subpages + */ + public function prefixSearchSubpages( $search, $limit, $offset ) { + $user = User::newFromName( $search ); + if ( !$user ) { + // No prefix suggestion for invalid user + return []; + } + // Autocomplete subpage as user list - public to allow caching + return UserNamePrefixSearch::search( 'public', $search, $limit, $offset ); + } + + protected function getGroupName() { + return 'users'; + } + + /** + * Builds an error message based on the block params + * + * @return ErrorPageError + */ + private function getBlockedEmailError() { + $block = $this->getUser()->mBlock; + $params = $block->getBlockErrorParams( $this->getContext() ); + + $msg = $block->isSitewide() ? 'blockedtext' : 'blocked-email-user'; + return new ErrorPageError( 'blockedtitle', $msg, $params ); + } +} diff --git a/includes/specials/SpecialEmailuser.php b/includes/specials/SpecialEmailuser.php deleted file mode 100644 index 5f80215632..0000000000 --- a/includes/specials/SpecialEmailuser.php +++ /dev/null @@ -1,533 +0,0 @@ -mTarget, $this->getUser() ); - if ( !$target instanceof User ) { - return $this->msg( 'emailuser-title-notarget' )->text(); - } - - return $this->msg( 'emailuser-title-target', $target->getName() )->text(); - } - - protected function getFormFields() { - $linkRenderer = $this->getLinkRenderer(); - return [ - 'From' => [ - 'type' => 'info', - 'raw' => 1, - 'default' => $linkRenderer->makeLink( - $this->getUser()->getUserPage(), - $this->getUser()->getName() - ), - 'label-message' => 'emailfrom', - 'id' => 'mw-emailuser-sender', - ], - 'To' => [ - 'type' => 'info', - 'raw' => 1, - 'default' => $linkRenderer->makeLink( - $this->mTargetObj->getUserPage(), - $this->mTargetObj->getName() - ), - 'label-message' => 'emailto', - 'id' => 'mw-emailuser-recipient', - ], - 'Target' => [ - 'type' => 'hidden', - 'default' => $this->mTargetObj->getName(), - ], - 'Subject' => [ - 'type' => 'text', - 'default' => $this->msg( 'defemailsubject', - $this->getUser()->getName() )->inContentLanguage()->text(), - 'label-message' => 'emailsubject', - 'maxlength' => 200, - 'size' => 60, - 'required' => true, - ], - 'Text' => [ - 'type' => 'textarea', - 'rows' => 20, - 'label-message' => 'emailmessage', - 'required' => true, - ], - 'CCMe' => [ - 'type' => 'check', - 'label-message' => 'emailccme', - 'default' => $this->getUser()->getBoolOption( 'ccmeonemails' ), - ], - ]; - } - - public function execute( $par ) { - $out = $this->getOutput(); - $request = $this->getRequest(); - $out->addModuleStyles( 'mediawiki.special' ); - - $this->mTarget = $par ?? $request->getVal( 'wpTarget', $request->getVal( 'target', '' ) ); - - // Make sure, that HTMLForm uses the correct target. - $request->setVal( 'wpTarget', $this->mTarget ); - - // This needs to be below assignment of $this->mTarget because - // getDescription() needs it to determine the correct page title. - $this->setHeaders(); - $this->outputHeader(); - - // error out if sending user cannot do this - $error = self::getPermissionsError( - $this->getUser(), - $this->getRequest()->getVal( 'wpEditToken' ), - $this->getConfig() - ); - - switch ( $error ) { - case null: - # Wahey! - break; - case 'badaccess': - throw new PermissionsError( 'sendemail' ); - case 'blockedemailuser': - throw $this->getBlockedEmailError(); - case 'actionthrottledtext': - throw new ThrottledError; - case 'mailnologin': - case 'usermaildisabled': - throw new ErrorPageError( $error, "{$error}text" ); - default: - # It's a hook error - list( $title, $msg, $params ) = $error; - throw new ErrorPageError( $title, $msg, $params ); - } - - // Make sure, that a submitted form isn't submitted to a subpage (which could be - // a non-existing username) - $context = new DerivativeContext( $this->getContext() ); - $context->setTitle( $this->getPageTitle() ); // Remove subpage - $this->setContext( $context ); - - // A little hack: HTMLForm will check $this->mTarget only, if the form was posted, not - // if the user opens Special:EmailUser/Florian (e.g.). So check, if the user did that - // and show the "Send email to user" form directly, if so. Show the "enter username" - // form, otherwise. - $this->mTargetObj = self::getTarget( $this->mTarget, $this->getUser() ); - if ( !$this->mTargetObj instanceof User ) { - $this->userForm( $this->mTarget ); - } else { - $this->sendEmailForm(); - } - } - - /** - * Validate target User - * - * @param string $target Target user name - * @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, User $sender = null ) { - if ( $sender === null ) { - wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' ); - } - - if ( $target == '' ) { - wfDebug( "Target is empty.\n" ); - - return 'notarget'; - } - - $nu = User::newFromName( $target ); - $error = self::validateTarget( $nu, $sender ); - - return $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'; - } - - if ( !$target->isEmailConfirmed() ) { - wfDebug( "User has no valid email.\n" ); - - return 'noemail'; - } - - if ( !$target->canReceiveEmail() ) { - wfDebug( "User does not allow user emails.\n" ); - - return 'nowikiemail'; - } - - if ( $sender !== null && !$target->getOption( 'email-allow-new-users' ) && - $sender->isNewbie() - ) { - wfDebug( "User does not allow user emails from new users.\n" ); - - return 'nowikiemail'; - } - - if ( $sender !== null ) { - $blacklist = $target->getOption( 'email-blacklist', '' ); - if ( $blacklist ) { - $blacklist = MultiUsernameFilter::splitIds( $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 ''; - } - - /** - * Check whether a user is allowed to send email - * - * @param User $user - * @param string $editToken Edit token - * @param Config|null $config optional for backwards compatibility - * @return null|string|array Null on success, string on error, or array on - * hook error - */ - public static function getPermissionsError( $user, $editToken, Config $config = null ) { - if ( $config === null ) { - wfDebug( __METHOD__ . ' called without a Config instance passed to it' ); - $config = MediaWikiServices::getInstance()->getMainConfig(); - } - if ( !$config->get( 'EnableEmail' ) || !$config->get( 'EnableUserEmail' ) ) { - return 'usermaildisabled'; - } - - // Run this before $user->isAllowed, to show appropriate message to anons (T160309) - if ( !$user->isEmailConfirmed() ) { - return 'mailnologin'; - } - - if ( !$user->isAllowed( 'sendemail' ) ) { - return 'badaccess'; - } - - if ( $user->isBlockedFromEmailuser() ) { - wfDebug( "User is blocked from sending e-mail.\n" ); - - return "blockedemailuser"; - } - - // Check the ping limiter without incrementing it - we'll check it - // again later and increment it on a successful send - if ( $user->pingLimiter( 'emailuser', 0 ) ) { - wfDebug( "Ping limiter triggered.\n" ); - - return 'actionthrottledtext'; - } - - $hookErr = false; - - Hooks::run( 'UserCanSendEmail', [ &$user, &$hookErr ] ); - Hooks::run( 'EmailUserPermissionsErrors', [ $user, $editToken, &$hookErr ] ); - - if ( $hookErr ) { - return $hookErr; - } - - return null; - } - - /** - * Form to ask for target user name. - * - * @param string $name User name submitted. - */ - protected function userForm( $name ) { - $htmlForm = HTMLForm::factory( 'ooui', [ - 'Target' => [ - 'type' => 'user', - 'exists' => true, - 'label' => $this->msg( 'emailusername' )->text(), - 'id' => 'emailusertarget', - 'autofocus' => true, - 'value' => $name, - ] - ], $this->getContext() ); - - $htmlForm - ->setMethod( 'post' ) - ->setSubmitCallback( [ $this, 'sendEmailForm' ] ) - ->setFormIdentifier( 'userForm' ) - ->setId( 'askusername' ) - ->setWrapperLegendMsg( 'emailtarget' ) - ->setSubmitTextMsg( 'emailusernamesubmit' ) - ->show(); - } - - public function sendEmailForm() { - $out = $this->getOutput(); - - $ret = $this->mTargetObj; - if ( !$ret instanceof User ) { - if ( $this->mTarget != '' ) { - // Messages used here: notargettext, noemailtext, nowikiemailtext - $ret = ( $ret == 'notarget' ) ? 'emailnotarget' : ( $ret . 'text' ); - return Status::newFatal( $ret ); - } - return false; - } - - $htmlForm = HTMLForm::factory( 'ooui', $this->getFormFields(), $this->getContext() ); - // By now we are supposed to be sure that $this->mTarget is a user name - $htmlForm - ->addPreText( $this->msg( 'emailpagetext', $this->mTarget )->parse() ) - ->setSubmitTextMsg( 'emailsend' ) - ->setSubmitCallback( [ __CLASS__, 'submit' ] ) - ->setFormIdentifier( 'sendEmailForm' ) - ->setWrapperLegendMsg( 'email-legend' ) - ->loadData(); - - if ( !Hooks::run( 'EmailUserForm', [ &$htmlForm ] ) ) { - return false; - } - - $result = $htmlForm->show(); - - if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) { - $out->setPageTitle( $this->msg( 'emailsent' ) ); - $out->addWikiMsg( 'emailsenttext', $this->mTarget ); - $out->returnToMain( false, $ret->getUserPage() ); - } - return true; - } - - /** - * Really send a mail. Permissions should have been checked using - * getPermissionsError(). It is probably also a good - * idea to check the edit token and ping limiter in advance. - * - * @param array $data - * @param IContextSource $context - * @return Status|bool - */ - public static function submit( array $data, IContextSource $context ) { - $config = $context->getConfig(); - - $target = self::getTarget( $data['Target'], $context->getUser() ); - if ( !$target instanceof User ) { - // Messages used here: notargettext, noemailtext, nowikiemailtext - return Status::newFatal( $target . 'text' ); - } - - $to = MailAddress::newFromUser( $target ); - $from = MailAddress::newFromUser( $context->getUser() ); - $subject = $data['Subject']; - $text = $data['Text']; - - // Add a standard footer and trim up trailing newlines - $text = rtrim( $text ) . "\n\n-- \n"; - $text .= $context->msg( 'emailuserfooter', - $from->name, $to->name )->inContentLanguage()->text(); - - // Check and increment the rate limits - if ( $context->getUser()->pingLimiter( 'emailuser' ) ) { - throw new ThrottledError(); - } - - $error = false; - if ( !Hooks::run( 'EmailUser', [ &$to, &$from, &$subject, &$text, &$error ] ) ) { - if ( $error instanceof Status ) { - return $error; - } elseif ( $error === false || $error === '' || $error === [] ) { - // Possibly to tell HTMLForm to pretend there was no submission? - return false; - } elseif ( $error === true ) { - // Hook sent the mail itself and indicates success? - return Status::newGood(); - } elseif ( is_array( $error ) ) { - $status = Status::newGood(); - foreach ( $error as $e ) { - $status->fatal( $e ); - } - return $status; - } elseif ( $error instanceof MessageSpecifier ) { - return Status::newFatal( $error ); - } else { - // Ugh. Either a raw HTML string, or something that's supposed - // to be treated like one. - $type = is_object( $error ) ? get_class( $error ) : gettype( $error ); - wfDeprecated( "EmailUser hook returning a $type as \$error", '1.29' ); - return Status::newFatal( new ApiRawMessage( - [ '$1', Message::rawParam( (string)$error ) ], 'hookaborted' - ) ); - } - } - - if ( $config->get( 'UserEmailUseReplyTo' ) ) { - /** - * Put the generic wiki autogenerated address in the From: - * header and reserve the user for Reply-To. - * - * This is a bit ugly, but will serve to differentiate - * wiki-borne mails from direct mails and protects against - * SPF and bounce problems with some mailers (see below). - */ - $mailFrom = new MailAddress( $config->get( 'PasswordSender' ), - $context->msg( 'emailsender' )->inContentLanguage()->text() ); - $replyTo = $from; - } else { - /** - * Put the sending user's e-mail address in the From: header. - * - * This is clean-looking and convenient, but has issues. - * One is that it doesn't as clearly differentiate the wiki mail - * from "directly" sent mails. - * - * Another is that some mailers (like sSMTP) will use the From - * address as the envelope sender as well. For open sites this - * can cause mails to be flunked for SPF violations (since the - * wiki server isn't an authorized sender for various users' - * domains) as well as creating a privacy issue as bounces - * containing the recipient's e-mail address may get sent to - * the sending user. - */ - $mailFrom = $from; - $replyTo = null; - } - - $status = UserMailer::send( $to, $mailFrom, $subject, $text, [ - 'replyTo' => $replyTo, - ] ); - - if ( !$status->isGood() ) { - return $status; - } else { - // if the user requested a copy of this mail, do this now, - // unless they are emailing themselves, in which case one - // copy of the message is sufficient. - if ( $data['CCMe'] && $to != $from ) { - $ccTo = $from; - $ccFrom = $from; - $ccSubject = $context->msg( 'emailccsubject' )->plaintextParams( - $target->getName(), $subject )->text(); - $ccText = $text; - - Hooks::run( 'EmailUserCC', [ &$ccTo, &$ccFrom, &$ccSubject, &$ccText ] ); - - if ( $config->get( 'UserEmailUseReplyTo' ) ) { - $mailFrom = new MailAddress( - $config->get( 'PasswordSender' ), - $context->msg( 'emailsender' )->inContentLanguage()->text() - ); - $replyTo = $ccFrom; - } else { - $mailFrom = $ccFrom; - $replyTo = null; - } - - $ccStatus = UserMailer::send( - $ccTo, $mailFrom, $ccSubject, $ccText, [ - 'replyTo' => $replyTo, - ] ); - $status->merge( $ccStatus ); - } - - Hooks::run( 'EmailUserComplete', [ $to, $from, $subject, $text ] ); - - return $status; - } - } - - /** - * Return an array of subpages beginning with $search that this special page will accept. - * - * @param string $search Prefix to search for - * @param int $limit Maximum number of results to return (usually 10) - * @param int $offset Number of results to skip (usually 0) - * @return string[] Matching subpages - */ - public function prefixSearchSubpages( $search, $limit, $offset ) { - $user = User::newFromName( $search ); - if ( !$user ) { - // No prefix suggestion for invalid user - return []; - } - // Autocomplete subpage as user list - public to allow caching - return UserNamePrefixSearch::search( 'public', $search, $limit, $offset ); - } - - protected function getGroupName() { - return 'users'; - } - - /** - * Builds an error message based on the block params - * - * @return ErrorPageError - */ - private function getBlockedEmailError() { - $block = $this->getUser()->mBlock; - $params = $block->getBlockErrorParams( $this->getContext() ); - - $msg = $block->isSitewide() ? 'blockedtext' : 'blocked-email-user'; - return new ErrorPageError( 'blockedtitle', $msg, $params ); - } -} diff --git a/includes/specials/SpecialListFiles.php b/includes/specials/SpecialListFiles.php new file mode 100644 index 0000000000..e6e1048cd6 --- /dev/null +++ b/includes/specials/SpecialListFiles.php @@ -0,0 +1,83 @@ +setHeaders(); + $this->outputHeader(); + + if ( $this->including() ) { + $userName = $par; + $search = ''; + $showAll = false; + } else { + $userName = $this->getRequest()->getText( 'user', $par ); + $search = $this->getRequest()->getText( 'ilsearch', '' ); + $showAll = $this->getRequest()->getBool( 'ilshowall', false ); + } + + $pager = new ImageListPager( + $this->getContext(), + $userName, + $search, + $this->including(), + $showAll + ); + + $out = $this->getOutput(); + if ( $this->including() ) { + $out->addParserOutputContent( $pager->getBodyOutput() ); + } else { + $user = $pager->getRelevantUser(); + $this->getSkin()->setRelevantUser( $user ); + $pager->getForm(); + $out->addParserOutputContent( $pager->getFullOutput() ); + } + } + + /** + * Return an array of subpages beginning with $search that this special page will accept. + * + * @param string $search Prefix to search for + * @param int $limit Maximum number of results to return (usually 10) + * @param int $offset Number of results to skip (usually 0) + * @return string[] Matching subpages + */ + public function prefixSearchSubpages( $search, $limit, $offset ) { + $user = User::newFromName( $search ); + if ( !$user ) { + // No prefix suggestion for invalid user + return []; + } + // Autocomplete subpage as user list - public to allow caching + return UserNamePrefixSearch::search( 'public', $search, $limit, $offset ); + } + + protected function getGroupName() { + return 'media'; + } +} diff --git a/includes/specials/SpecialListGrants.php b/includes/specials/SpecialListGrants.php new file mode 100644 index 0000000000..ba16baf924 --- /dev/null +++ b/includes/specials/SpecialListGrants.php @@ -0,0 +1,91 @@ +setHeaders(); + $this->outputHeader(); + + $out = $this->getOutput(); + $out->addModuleStyles( 'mediawiki.special' ); + + $out->addHTML( + \Html::openElement( 'table', + [ 'class' => 'wikitable mw-listgrouprights-table' ] ) . + '' . + \Html::element( 'th', null, $this->msg( 'listgrants-grant' )->text() ) . + \Html::element( 'th', null, $this->msg( 'listgrants-rights' )->text() ) . + '' + ); + + foreach ( $this->getConfig()->get( 'GrantPermissions' ) as $grant => $rights ) { + $descs = []; + $rights = array_filter( $rights ); // remove ones with 'false' + foreach ( $rights as $permission => $granted ) { + $descs[] = $this->msg( + 'listgrouprights-right-display', + \User::getRightDescription( $permission ), + '' . $permission . '' + )->parse(); + } + if ( $descs === [] ) { + $grantCellHtml = ''; + } else { + sort( $descs ); + $grantCellHtml = ''; + } + + $id = Sanitizer::escapeIdForAttribute( $grant ); + $out->addHTML( \Html::rawElement( 'tr', [ 'id' => $id ], + "" . + $this->msg( + "listgrants-grant-display", + \User::getGrantName( $grant ), + "" . $id . "" + )->parse() . + "" . + "" . $grantCellHtml . "" + ) ); + } + + $out->addHTML( \Html::closeElement( 'table' ) ); + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/includes/specials/SpecialListGroupRights.php b/includes/specials/SpecialListGroupRights.php new file mode 100644 index 0000000000..1d10791363 --- /dev/null +++ b/includes/specials/SpecialListGroupRights.php @@ -0,0 +1,293 @@ + + */ +class SpecialListGroupRights extends SpecialPage { + public function __construct() { + parent::__construct( 'Listgrouprights' ); + } + + /** + * Show the special page + * @param string|null $par + */ + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + + $out = $this->getOutput(); + $out->addModuleStyles( 'mediawiki.special' ); + + $out->wrapWikiMsg( "
\n$1\n
", 'listgrouprights-key' ); + + $out->addHTML( + Xml::openElement( 'table', [ 'class' => 'wikitable mw-listgrouprights-table' ] ) . + '' . + Xml::element( 'th', null, $this->msg( 'listgrouprights-group' )->text() ) . + Xml::element( 'th', null, $this->msg( 'listgrouprights-rights' )->text() ) . + '' + ); + + $config = $this->getConfig(); + $groupPermissions = $config->get( 'GroupPermissions' ); + $revokePermissions = $config->get( 'RevokePermissions' ); + $addGroups = $config->get( 'AddGroups' ); + $removeGroups = $config->get( 'RemoveGroups' ); + $groupsAddToSelf = $config->get( 'GroupsAddToSelf' ); + $groupsRemoveFromSelf = $config->get( 'GroupsRemoveFromSelf' ); + $allGroups = array_unique( array_merge( + array_keys( $groupPermissions ), + array_keys( $revokePermissions ), + array_keys( $addGroups ), + array_keys( $removeGroups ), + array_keys( $groupsAddToSelf ), + array_keys( $groupsRemoveFromSelf ) + ) ); + asort( $allGroups ); + + $linkRenderer = $this->getLinkRenderer(); + + foreach ( $allGroups as $group ) { + $permissions = $groupPermissions[$group] ?? []; + $groupname = ( $group == '*' ) // Replace * with a more descriptive groupname + ? 'all' + : $group; + + $groupnameLocalized = UserGroupMembership::getGroupName( $groupname ); + + $grouppageLocalizedTitle = UserGroupMembership::getGroupPage( $groupname ) + ?: Title::newFromText( MWNamespace::getCanonicalName( NS_PROJECT ) . ':' . $groupname ); + + if ( $group == '*' || !$grouppageLocalizedTitle ) { + // Do not make a link for the generic * group or group with invalid group page + $grouppage = htmlspecialchars( $groupnameLocalized ); + } else { + $grouppage = $linkRenderer->makeLink( + $grouppageLocalizedTitle, + $groupnameLocalized + ); + } + + if ( $group === 'user' ) { + // Link to Special:listusers for implicit group 'user' + $grouplink = '
' . $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Listusers' ), + $this->msg( 'listgrouprights-members' )->text() + ); + } elseif ( !in_array( $group, $config->get( 'ImplicitGroups' ) ) ) { + $grouplink = '
' . $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Listusers' ), + $this->msg( 'listgrouprights-members' )->text(), + [], + [ 'group' => $group ] + ); + } else { + // No link to Special:listusers for other implicit groups as they are unlistable + $grouplink = ''; + } + + $revoke = $revokePermissions[$group] ?? []; + $addgroups = $addGroups[$group] ?? []; + $removegroups = $removeGroups[$group] ?? []; + $addgroupsSelf = $groupsAddToSelf[$group] ?? []; + $removegroupsSelf = $groupsRemoveFromSelf[$group] ?? []; + + $id = $group == '*' ? false : Sanitizer::escapeIdForAttribute( $group ); + $out->addHTML( Html::rawElement( 'tr', [ 'id' => $id ], " + $grouppage$grouplink + " . + $this->formatPermissions( $permissions, $revoke, $addgroups, $removegroups, + $addgroupsSelf, $removegroupsSelf ) . + ' + ' + ) ); + } + $out->addHTML( Xml::closeElement( 'table' ) ); + $this->outputNamespaceProtectionInfo(); + } + + private function outputNamespaceProtectionInfo() { + $out = $this->getOutput(); + $namespaceProtection = $this->getConfig()->get( 'NamespaceProtection' ); + + if ( count( $namespaceProtection ) == 0 ) { + return; + } + + $header = $this->msg( 'listgrouprights-namespaceprotection-header' )->text(); + $out->addHTML( + Html::rawElement( 'h2', [], Html::element( 'span', [ + 'class' => 'mw-headline', + 'id' => substr( Parser::guessSectionNameFromStrippedText( $header ), 1 ) + ], $header ) ) . + Xml::openElement( 'table', [ 'class' => 'wikitable' ] ) . + Html::element( + 'th', + [], + $this->msg( 'listgrouprights-namespaceprotection-namespace' )->text() + ) . + Html::element( + 'th', + [], + $this->msg( 'listgrouprights-namespaceprotection-restrictedto' )->text() + ) + ); + $linkRenderer = $this->getLinkRenderer(); + ksort( $namespaceProtection ); + $validNamespaces = MWNamespace::getValidNamespaces(); + $contLang = MediaWikiServices::getInstance()->getContentLanguage(); + foreach ( $namespaceProtection as $namespace => $rights ) { + if ( !in_array( $namespace, $validNamespaces ) ) { + continue; + } + + if ( $namespace == NS_MAIN ) { + $namespaceText = $this->msg( 'blanknamespace' )->text(); + } else { + $namespaceText = $contLang->convertNamespace( $namespace ); + } + + $out->addHTML( + Xml::openElement( 'tr' ) . + Html::rawElement( + 'td', + [], + $linkRenderer->makeLink( + SpecialPage::getTitleFor( 'Allpages' ), + $namespaceText, + [], + [ 'namespace' => $namespace ] + ) + ) . + Xml::openElement( 'td' ) . Xml::openElement( 'ul' ) + ); + + if ( !is_array( $rights ) ) { + $rights = [ $rights ]; + } + + foreach ( $rights as $right ) { + $out->addHTML( + Html::rawElement( 'li', [], $this->msg( + 'listgrouprights-right-display', + User::getRightDescription( $right ), + Html::element( + 'span', + [ 'class' => 'mw-listgrouprights-right-name' ], + $right + ) + )->parse() ) + ); + } + + $out->addHTML( + Xml::closeElement( 'ul' ) . + Xml::closeElement( 'td' ) . + Xml::closeElement( 'tr' ) + ); + } + $out->addHTML( Xml::closeElement( 'table' ) ); + } + + /** + * Create a user-readable list of permissions from the given array. + * + * @param array $permissions Array of permission => bool (from $wgGroupPermissions items) + * @param array $revoke Array of permission => bool (from $wgRevokePermissions items) + * @param array $add Array of groups this group is allowed to add or true + * @param array $remove Array of groups this group is allowed to remove or true + * @param array $addSelf Array of groups this group is allowed to add to self or true + * @param array $removeSelf Array of group this group is allowed to remove from self or true + * @return string HTML list of all granted permissions + */ + private function formatPermissions( $permissions, $revoke, $add, $remove, $addSelf, $removeSelf ) { + $r = []; + foreach ( $permissions as $permission => $granted ) { + // show as granted only if it isn't revoked to prevent duplicate display of permissions + if ( $granted && ( !isset( $revoke[$permission] ) || !$revoke[$permission] ) ) { + $r[] = $this->msg( 'listgrouprights-right-display', + User::getRightDescription( $permission ), + '' . $permission . '' + )->parse(); + } + } + foreach ( $revoke as $permission => $revoked ) { + if ( $revoked ) { + $r[] = $this->msg( 'listgrouprights-right-revoked', + User::getRightDescription( $permission ), + '' . $permission . '' + )->parse(); + } + } + + sort( $r ); + + $lang = $this->getLanguage(); + $allGroups = User::getAllGroups(); + + $changeGroups = [ + 'addgroup' => $add, + 'removegroup' => $remove, + 'addgroup-self' => $addSelf, + 'removegroup-self' => $removeSelf + ]; + + foreach ( $changeGroups as $messageKey => $changeGroup ) { + if ( $changeGroup === true ) { + // For grep: listgrouprights-addgroup-all, listgrouprights-removegroup-all, + // listgrouprights-addgroup-self-all, listgrouprights-removegroup-self-all + $r[] = $this->msg( 'listgrouprights-' . $messageKey . '-all' )->escaped(); + } elseif ( is_array( $changeGroup ) ) { + $changeGroup = array_intersect( array_values( array_unique( $changeGroup ) ), $allGroups ); + if ( count( $changeGroup ) ) { + $groupLinks = []; + foreach ( $changeGroup as $group ) { + $groupLinks[] = UserGroupMembership::getLink( $group, $this->getContext(), 'wiki' ); + } + // For grep: listgrouprights-addgroup, listgrouprights-removegroup, + // listgrouprights-addgroup-self, listgrouprights-removegroup-self + $r[] = $this->msg( 'listgrouprights-' . $messageKey, + $lang->listToText( $groupLinks ), count( $changeGroup ) )->parse(); + } + } + } + + if ( empty( $r ) ) { + return ''; + } else { + return ''; + } + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/includes/specials/SpecialListUsers.php b/includes/specials/SpecialListUsers.php new file mode 100644 index 0000000000..7aef4aef28 --- /dev/null +++ b/includes/specials/SpecialListUsers.php @@ -0,0 +1,77 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @ingroup SpecialPage + */ + +/** + * @ingroup SpecialPage + */ +class SpecialListUsers extends IncludableSpecialPage { + + public function __construct() { + parent::__construct( 'Listusers' ); + } + + /** + * @param string|null $par (optional) A group to list users from + */ + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + + $up = new UsersPager( $this->getContext(), $par, $this->including() ); + + # getBody() first to check, if empty + $usersbody = $up->getBody(); + + $s = ''; + if ( !$this->including() ) { + $s = $up->getPageHeader(); + } + + if ( $usersbody ) { + $s .= $up->getNavigationBar(); + $s .= Html::rawElement( 'ul', [], $usersbody ); + $s .= $up->getNavigationBar(); + } else { + $s .= $this->msg( 'listusers-noresult' )->parseAsBlock(); + } + + $this->getOutput()->addHTML( $s ); + } + + /** + * Return an array of subpages that this special page will accept. + * + * @return string[] subpages + */ + public function getSubpagesForPrefixSearch() { + return User::getAllGroups(); + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/includes/specials/SpecialListfiles.php b/includes/specials/SpecialListfiles.php deleted file mode 100644 index e6e1048cd6..0000000000 --- a/includes/specials/SpecialListfiles.php +++ /dev/null @@ -1,83 +0,0 @@ -setHeaders(); - $this->outputHeader(); - - if ( $this->including() ) { - $userName = $par; - $search = ''; - $showAll = false; - } else { - $userName = $this->getRequest()->getText( 'user', $par ); - $search = $this->getRequest()->getText( 'ilsearch', '' ); - $showAll = $this->getRequest()->getBool( 'ilshowall', false ); - } - - $pager = new ImageListPager( - $this->getContext(), - $userName, - $search, - $this->including(), - $showAll - ); - - $out = $this->getOutput(); - if ( $this->including() ) { - $out->addParserOutputContent( $pager->getBodyOutput() ); - } else { - $user = $pager->getRelevantUser(); - $this->getSkin()->setRelevantUser( $user ); - $pager->getForm(); - $out->addParserOutputContent( $pager->getFullOutput() ); - } - } - - /** - * Return an array of subpages beginning with $search that this special page will accept. - * - * @param string $search Prefix to search for - * @param int $limit Maximum number of results to return (usually 10) - * @param int $offset Number of results to skip (usually 0) - * @return string[] Matching subpages - */ - public function prefixSearchSubpages( $search, $limit, $offset ) { - $user = User::newFromName( $search ); - if ( !$user ) { - // No prefix suggestion for invalid user - return []; - } - // Autocomplete subpage as user list - public to allow caching - return UserNamePrefixSearch::search( 'public', $search, $limit, $offset ); - } - - protected function getGroupName() { - return 'media'; - } -} diff --git a/includes/specials/SpecialListgrants.php b/includes/specials/SpecialListgrants.php deleted file mode 100644 index ba16baf924..0000000000 --- a/includes/specials/SpecialListgrants.php +++ /dev/null @@ -1,91 +0,0 @@ -setHeaders(); - $this->outputHeader(); - - $out = $this->getOutput(); - $out->addModuleStyles( 'mediawiki.special' ); - - $out->addHTML( - \Html::openElement( 'table', - [ 'class' => 'wikitable mw-listgrouprights-table' ] ) . - '' . - \Html::element( 'th', null, $this->msg( 'listgrants-grant' )->text() ) . - \Html::element( 'th', null, $this->msg( 'listgrants-rights' )->text() ) . - '' - ); - - foreach ( $this->getConfig()->get( 'GrantPermissions' ) as $grant => $rights ) { - $descs = []; - $rights = array_filter( $rights ); // remove ones with 'false' - foreach ( $rights as $permission => $granted ) { - $descs[] = $this->msg( - 'listgrouprights-right-display', - \User::getRightDescription( $permission ), - '' . $permission . '' - )->parse(); - } - if ( $descs === [] ) { - $grantCellHtml = ''; - } else { - sort( $descs ); - $grantCellHtml = ''; - } - - $id = Sanitizer::escapeIdForAttribute( $grant ); - $out->addHTML( \Html::rawElement( 'tr', [ 'id' => $id ], - "" . - $this->msg( - "listgrants-grant-display", - \User::getGrantName( $grant ), - "" . $id . "" - )->parse() . - "" . - "" . $grantCellHtml . "" - ) ); - } - - $out->addHTML( \Html::closeElement( 'table' ) ); - } - - protected function getGroupName() { - return 'users'; - } -} diff --git a/includes/specials/SpecialListgrouprights.php b/includes/specials/SpecialListgrouprights.php deleted file mode 100644 index 1d10791363..0000000000 --- a/includes/specials/SpecialListgrouprights.php +++ /dev/null @@ -1,293 +0,0 @@ - - */ -class SpecialListGroupRights extends SpecialPage { - public function __construct() { - parent::__construct( 'Listgrouprights' ); - } - - /** - * Show the special page - * @param string|null $par - */ - public function execute( $par ) { - $this->setHeaders(); - $this->outputHeader(); - - $out = $this->getOutput(); - $out->addModuleStyles( 'mediawiki.special' ); - - $out->wrapWikiMsg( "
\n$1\n
", 'listgrouprights-key' ); - - $out->addHTML( - Xml::openElement( 'table', [ 'class' => 'wikitable mw-listgrouprights-table' ] ) . - '' . - Xml::element( 'th', null, $this->msg( 'listgrouprights-group' )->text() ) . - Xml::element( 'th', null, $this->msg( 'listgrouprights-rights' )->text() ) . - '' - ); - - $config = $this->getConfig(); - $groupPermissions = $config->get( 'GroupPermissions' ); - $revokePermissions = $config->get( 'RevokePermissions' ); - $addGroups = $config->get( 'AddGroups' ); - $removeGroups = $config->get( 'RemoveGroups' ); - $groupsAddToSelf = $config->get( 'GroupsAddToSelf' ); - $groupsRemoveFromSelf = $config->get( 'GroupsRemoveFromSelf' ); - $allGroups = array_unique( array_merge( - array_keys( $groupPermissions ), - array_keys( $revokePermissions ), - array_keys( $addGroups ), - array_keys( $removeGroups ), - array_keys( $groupsAddToSelf ), - array_keys( $groupsRemoveFromSelf ) - ) ); - asort( $allGroups ); - - $linkRenderer = $this->getLinkRenderer(); - - foreach ( $allGroups as $group ) { - $permissions = $groupPermissions[$group] ?? []; - $groupname = ( $group == '*' ) // Replace * with a more descriptive groupname - ? 'all' - : $group; - - $groupnameLocalized = UserGroupMembership::getGroupName( $groupname ); - - $grouppageLocalizedTitle = UserGroupMembership::getGroupPage( $groupname ) - ?: Title::newFromText( MWNamespace::getCanonicalName( NS_PROJECT ) . ':' . $groupname ); - - if ( $group == '*' || !$grouppageLocalizedTitle ) { - // Do not make a link for the generic * group or group with invalid group page - $grouppage = htmlspecialchars( $groupnameLocalized ); - } else { - $grouppage = $linkRenderer->makeLink( - $grouppageLocalizedTitle, - $groupnameLocalized - ); - } - - if ( $group === 'user' ) { - // Link to Special:listusers for implicit group 'user' - $grouplink = '
' . $linkRenderer->makeKnownLink( - SpecialPage::getTitleFor( 'Listusers' ), - $this->msg( 'listgrouprights-members' )->text() - ); - } elseif ( !in_array( $group, $config->get( 'ImplicitGroups' ) ) ) { - $grouplink = '
' . $linkRenderer->makeKnownLink( - SpecialPage::getTitleFor( 'Listusers' ), - $this->msg( 'listgrouprights-members' )->text(), - [], - [ 'group' => $group ] - ); - } else { - // No link to Special:listusers for other implicit groups as they are unlistable - $grouplink = ''; - } - - $revoke = $revokePermissions[$group] ?? []; - $addgroups = $addGroups[$group] ?? []; - $removegroups = $removeGroups[$group] ?? []; - $addgroupsSelf = $groupsAddToSelf[$group] ?? []; - $removegroupsSelf = $groupsRemoveFromSelf[$group] ?? []; - - $id = $group == '*' ? false : Sanitizer::escapeIdForAttribute( $group ); - $out->addHTML( Html::rawElement( 'tr', [ 'id' => $id ], " - $grouppage$grouplink - " . - $this->formatPermissions( $permissions, $revoke, $addgroups, $removegroups, - $addgroupsSelf, $removegroupsSelf ) . - ' - ' - ) ); - } - $out->addHTML( Xml::closeElement( 'table' ) ); - $this->outputNamespaceProtectionInfo(); - } - - private function outputNamespaceProtectionInfo() { - $out = $this->getOutput(); - $namespaceProtection = $this->getConfig()->get( 'NamespaceProtection' ); - - if ( count( $namespaceProtection ) == 0 ) { - return; - } - - $header = $this->msg( 'listgrouprights-namespaceprotection-header' )->text(); - $out->addHTML( - Html::rawElement( 'h2', [], Html::element( 'span', [ - 'class' => 'mw-headline', - 'id' => substr( Parser::guessSectionNameFromStrippedText( $header ), 1 ) - ], $header ) ) . - Xml::openElement( 'table', [ 'class' => 'wikitable' ] ) . - Html::element( - 'th', - [], - $this->msg( 'listgrouprights-namespaceprotection-namespace' )->text() - ) . - Html::element( - 'th', - [], - $this->msg( 'listgrouprights-namespaceprotection-restrictedto' )->text() - ) - ); - $linkRenderer = $this->getLinkRenderer(); - ksort( $namespaceProtection ); - $validNamespaces = MWNamespace::getValidNamespaces(); - $contLang = MediaWikiServices::getInstance()->getContentLanguage(); - foreach ( $namespaceProtection as $namespace => $rights ) { - if ( !in_array( $namespace, $validNamespaces ) ) { - continue; - } - - if ( $namespace == NS_MAIN ) { - $namespaceText = $this->msg( 'blanknamespace' )->text(); - } else { - $namespaceText = $contLang->convertNamespace( $namespace ); - } - - $out->addHTML( - Xml::openElement( 'tr' ) . - Html::rawElement( - 'td', - [], - $linkRenderer->makeLink( - SpecialPage::getTitleFor( 'Allpages' ), - $namespaceText, - [], - [ 'namespace' => $namespace ] - ) - ) . - Xml::openElement( 'td' ) . Xml::openElement( 'ul' ) - ); - - if ( !is_array( $rights ) ) { - $rights = [ $rights ]; - } - - foreach ( $rights as $right ) { - $out->addHTML( - Html::rawElement( 'li', [], $this->msg( - 'listgrouprights-right-display', - User::getRightDescription( $right ), - Html::element( - 'span', - [ 'class' => 'mw-listgrouprights-right-name' ], - $right - ) - )->parse() ) - ); - } - - $out->addHTML( - Xml::closeElement( 'ul' ) . - Xml::closeElement( 'td' ) . - Xml::closeElement( 'tr' ) - ); - } - $out->addHTML( Xml::closeElement( 'table' ) ); - } - - /** - * Create a user-readable list of permissions from the given array. - * - * @param array $permissions Array of permission => bool (from $wgGroupPermissions items) - * @param array $revoke Array of permission => bool (from $wgRevokePermissions items) - * @param array $add Array of groups this group is allowed to add or true - * @param array $remove Array of groups this group is allowed to remove or true - * @param array $addSelf Array of groups this group is allowed to add to self or true - * @param array $removeSelf Array of group this group is allowed to remove from self or true - * @return string HTML list of all granted permissions - */ - private function formatPermissions( $permissions, $revoke, $add, $remove, $addSelf, $removeSelf ) { - $r = []; - foreach ( $permissions as $permission => $granted ) { - // show as granted only if it isn't revoked to prevent duplicate display of permissions - if ( $granted && ( !isset( $revoke[$permission] ) || !$revoke[$permission] ) ) { - $r[] = $this->msg( 'listgrouprights-right-display', - User::getRightDescription( $permission ), - '' . $permission . '' - )->parse(); - } - } - foreach ( $revoke as $permission => $revoked ) { - if ( $revoked ) { - $r[] = $this->msg( 'listgrouprights-right-revoked', - User::getRightDescription( $permission ), - '' . $permission . '' - )->parse(); - } - } - - sort( $r ); - - $lang = $this->getLanguage(); - $allGroups = User::getAllGroups(); - - $changeGroups = [ - 'addgroup' => $add, - 'removegroup' => $remove, - 'addgroup-self' => $addSelf, - 'removegroup-self' => $removeSelf - ]; - - foreach ( $changeGroups as $messageKey => $changeGroup ) { - if ( $changeGroup === true ) { - // For grep: listgrouprights-addgroup-all, listgrouprights-removegroup-all, - // listgrouprights-addgroup-self-all, listgrouprights-removegroup-self-all - $r[] = $this->msg( 'listgrouprights-' . $messageKey . '-all' )->escaped(); - } elseif ( is_array( $changeGroup ) ) { - $changeGroup = array_intersect( array_values( array_unique( $changeGroup ) ), $allGroups ); - if ( count( $changeGroup ) ) { - $groupLinks = []; - foreach ( $changeGroup as $group ) { - $groupLinks[] = UserGroupMembership::getLink( $group, $this->getContext(), 'wiki' ); - } - // For grep: listgrouprights-addgroup, listgrouprights-removegroup, - // listgrouprights-addgroup-self, listgrouprights-removegroup-self - $r[] = $this->msg( 'listgrouprights-' . $messageKey, - $lang->listToText( $groupLinks ), count( $changeGroup ) )->parse(); - } - } - } - - if ( empty( $r ) ) { - return ''; - } else { - return ''; - } - } - - protected function getGroupName() { - return 'users'; - } -} diff --git a/includes/specials/SpecialListusers.php b/includes/specials/SpecialListusers.php deleted file mode 100644 index 7aef4aef28..0000000000 --- a/includes/specials/SpecialListusers.php +++ /dev/null @@ -1,77 +0,0 @@ - - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, write to the Free Software Foundation, Inc., - * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - * http://www.gnu.org/copyleft/gpl.html - * - * @file - * @ingroup SpecialPage - */ - -/** - * @ingroup SpecialPage - */ -class SpecialListUsers extends IncludableSpecialPage { - - public function __construct() { - parent::__construct( 'Listusers' ); - } - - /** - * @param string|null $par (optional) A group to list users from - */ - public function execute( $par ) { - $this->setHeaders(); - $this->outputHeader(); - - $up = new UsersPager( $this->getContext(), $par, $this->including() ); - - # getBody() first to check, if empty - $usersbody = $up->getBody(); - - $s = ''; - if ( !$this->including() ) { - $s = $up->getPageHeader(); - } - - if ( $usersbody ) { - $s .= $up->getNavigationBar(); - $s .= Html::rawElement( 'ul', [], $usersbody ); - $s .= $up->getNavigationBar(); - } else { - $s .= $this->msg( 'listusers-noresult' )->parseAsBlock(); - } - - $this->getOutput()->addHTML( $s ); - } - - /** - * Return an array of subpages that this special page will accept. - * - * @return string[] subpages - */ - public function getSubpagesForPrefixSearch() { - return User::getAllGroups(); - } - - protected function getGroupName() { - return 'users'; - } -} diff --git a/includes/specials/SpecialRecentChanges.php b/includes/specials/SpecialRecentChanges.php new file mode 100644 index 0000000000..c8f65c1bcb --- /dev/null +++ b/includes/specials/SpecialRecentChanges.php @@ -0,0 +1,945 @@ +watchlistFilterGroupDefinition = [ + 'name' => 'watchlist', + 'title' => 'rcfilters-filtergroup-watchlist', + 'class' => ChangesListStringOptionsFilterGroup::class, + 'priority' => -9, + 'isFullCoverage' => true, + 'filters' => [ + [ + 'name' => 'watched', + 'label' => 'rcfilters-filter-watchlist-watched-label', + 'description' => 'rcfilters-filter-watchlist-watched-description', + 'cssClassSuffix' => 'watched', + 'isRowApplicableCallable' => function ( $ctx, $rc ) { + return $rc->getAttribute( 'wl_user' ); + } + ], + [ + 'name' => 'watchednew', + 'label' => 'rcfilters-filter-watchlist-watchednew-label', + 'description' => 'rcfilters-filter-watchlist-watchednew-description', + 'cssClassSuffix' => 'watchednew', + 'isRowApplicableCallable' => function ( $ctx, $rc ) { + return $rc->getAttribute( 'wl_user' ) && + $rc->getAttribute( 'rc_timestamp' ) && + $rc->getAttribute( 'wl_notificationtimestamp' ) && + $rc->getAttribute( 'rc_timestamp' ) >= $rc->getAttribute( 'wl_notificationtimestamp' ); + }, + ], + [ + 'name' => 'notwatched', + 'label' => 'rcfilters-filter-watchlist-notwatched-label', + 'description' => 'rcfilters-filter-watchlist-notwatched-description', + 'cssClassSuffix' => 'notwatched', + 'isRowApplicableCallable' => function ( $ctx, $rc ) { + return $rc->getAttribute( 'wl_user' ) === null; + }, + ] + ], + 'default' => ChangesListStringOptionsFilterGroup::NONE, + 'queryCallable' => function ( $specialPageClassName, $context, $dbr, + &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues ) { + sort( $selectedValues ); + $notwatchedCond = 'wl_user IS NULL'; + $watchedCond = 'wl_user IS NOT NULL'; + $newCond = 'rc_timestamp >= wl_notificationtimestamp'; + + if ( $selectedValues === [ 'notwatched' ] ) { + $conds[] = $notwatchedCond; + return; + } + + if ( $selectedValues === [ 'watched' ] ) { + $conds[] = $watchedCond; + return; + } + + if ( $selectedValues === [ 'watchednew' ] ) { + $conds[] = $dbr->makeList( [ + $watchedCond, + $newCond + ], LIST_AND ); + return; + } + + if ( $selectedValues === [ 'notwatched', 'watched' ] ) { + // no filters + return; + } + + if ( $selectedValues === [ 'notwatched', 'watchednew' ] ) { + $conds[] = $dbr->makeList( [ + $notwatchedCond, + $dbr->makeList( [ + $watchedCond, + $newCond + ], LIST_AND ) + ], LIST_OR ); + return; + } + + if ( $selectedValues === [ 'watched', 'watchednew' ] ) { + $conds[] = $watchedCond; + return; + } + + if ( $selectedValues === [ 'notwatched', 'watched', 'watchednew' ] ) { + // no filters + return; + } + } + ]; + } + + /** + * @param string|null $subpage + */ + public function execute( $subpage ) { + // Backwards-compatibility: redirect to new feed URLs + $feedFormat = $this->getRequest()->getVal( 'feed' ); + if ( !$this->including() && $feedFormat ) { + $query = $this->getFeedQuery(); + $query['feedformat'] = $feedFormat === 'atom' ? 'atom' : 'rss'; + $this->getOutput()->redirect( wfAppendQuery( wfScript( 'api' ), $query ) ); + + return; + } + + // 10 seconds server-side caching max + $out = $this->getOutput(); + $out->setCdnMaxage( 10 ); + // Check if the client has a cached version + $lastmod = $this->checkLastModified(); + if ( $lastmod === false ) { + return; + } + + $this->addHelpLink( + '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:Recent_changes', + true + ); + parent::execute( $subpage ); + } + + /** + * @inheritDoc + */ + protected function transformFilterDefinition( array $filterDefinition ) { + if ( isset( $filterDefinition['showHideSuffix'] ) ) { + $filterDefinition['showHide'] = 'rc' . $filterDefinition['showHideSuffix']; + } + + return $filterDefinition; + } + + /** + * @inheritDoc + */ + protected function registerFilters() { + parent::registerFilters(); + + if ( + !$this->including() && + $this->getUser()->isLoggedIn() && + $this->getUser()->isAllowed( 'viewmywatchlist' ) + ) { + $this->registerFiltersFromDefinitions( [ $this->watchlistFilterGroupDefinition ] ); + $watchlistGroup = $this->getFilterGroup( 'watchlist' ); + $watchlistGroup->getFilter( 'watched' )->setAsSupersetOf( + $watchlistGroup->getFilter( 'watchednew' ) + ); + } + + $user = $this->getUser(); + + $significance = $this->getFilterGroup( 'significance' ); + $hideMinor = $significance->getFilter( 'hideminor' ); + $hideMinor->setDefault( $user->getBoolOption( 'hideminor' ) ); + + $automated = $this->getFilterGroup( 'automated' ); + $hideBots = $automated->getFilter( 'hidebots' ); + $hideBots->setDefault( true ); + + $reviewStatus = $this->getFilterGroup( 'reviewStatus' ); + if ( $reviewStatus !== null ) { + // Conditional on feature being available and rights + if ( $user->getBoolOption( 'hidepatrolled' ) ) { + $reviewStatus->setDefault( 'unpatrolled' ); + $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' ); + $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' ); + $legacyHidePatrolled->setDefault( true ); + } + } + + $changeType = $this->getFilterGroup( 'changeType' ); + $hideCategorization = $changeType->getFilter( 'hidecategorization' ); + if ( $hideCategorization !== null ) { + // Conditional on feature being available + $hideCategorization->setDefault( $user->getBoolOption( 'hidecategorization' ) ); + } + } + + /** + * Process $par and put options found in $opts. Used when including the page. + * + * @param string $par + * @param FormOptions $opts + */ + public function parseParameters( $par, FormOptions $opts ) { + parent::parseParameters( $par, $opts ); + + $bits = preg_split( '/\s*,\s*/', trim( $par ) ); + foreach ( $bits as $bit ) { + if ( is_numeric( $bit ) ) { + $opts['limit'] = $bit; + } + + $m = []; + if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) { + $opts['limit'] = $m[1]; + } + if ( preg_match( '/^days=(\d+(?:\.\d+)?)$/', $bit, $m ) ) { + $opts['days'] = $m[1]; + } + if ( preg_match( '/^namespace=(.*)$/', $bit, $m ) ) { + $opts['namespace'] = $m[1]; + } + if ( preg_match( '/^tagfilter=(.*)$/', $bit, $m ) ) { + $opts['tagfilter'] = $m[1]; + } + } + } + + /** + * @inheritDoc + */ + protected function doMainQuery( $tables, $fields, $conds, $query_options, + $join_conds, FormOptions $opts + ) { + $dbr = $this->getDB(); + $user = $this->getUser(); + + $rcQuery = RecentChange::getQueryInfo(); + $tables = array_merge( $tables, $rcQuery['tables'] ); + $fields = array_merge( $rcQuery['fields'], $fields ); + $join_conds = array_merge( $join_conds, $rcQuery['joins'] ); + + // JOIN on watchlist for users + if ( $user->isLoggedIn() && $user->isAllowed( 'viewmywatchlist' ) ) { + $tables[] = 'watchlist'; + $fields[] = 'wl_user'; + $fields[] = 'wl_notificationtimestamp'; + $join_conds['watchlist'] = [ 'LEFT JOIN', [ + 'wl_user' => $user->getId(), + 'wl_title=rc_title', + 'wl_namespace=rc_namespace' + ] ]; + } + + // JOIN on page, used for 'last revision' filter highlight + $tables[] = 'page'; + $fields[] = 'page_latest'; + $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ]; + + $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : []; + ChangeTags::modifyDisplayQuery( + $tables, + $fields, + $conds, + $join_conds, + $query_options, + $tagFilter + ); + + if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, + $opts ) + ) { + return false; + } + + if ( $this->areFiltersInConflict() ) { + return false; + } + + $orderByAndLimit = [ + 'ORDER BY' => 'rc_timestamp DESC', + 'LIMIT' => $opts['limit'] + ]; + if ( in_array( 'DISTINCT', $query_options ) ) { + // ChangeTags::modifyDisplayQuery() adds DISTINCT when filtering on multiple tags. + // In order to prevent DISTINCT from causing query performance problems, + // we have to GROUP BY the primary key. This in turn requires us to add + // the primary key to the end of the ORDER BY, and the old ORDER BY to the + // start of the GROUP BY + $orderByAndLimit['ORDER BY'] = 'rc_timestamp DESC, rc_id DESC'; + $orderByAndLimit['GROUP BY'] = 'rc_timestamp, rc_id'; + } + // array_merge() is used intentionally here so that hooks can, should + // they so desire, override the ORDER BY / LIMIT condition(s); prior to + // MediaWiki 1.26 this used to use the plus operator instead, which meant + // that extensions weren't able to change these conditions + $query_options = array_merge( $orderByAndLimit, $query_options ); + $rows = $dbr->select( + $tables, + $fields, + // rc_new is not an ENUM, but adding a redundant rc_new IN (0,1) gives mysql enough + // knowledge to use an index merge if it wants (it may use some other index though). + $conds + [ 'rc_new' => [ 0, 1 ] ], + __METHOD__, + $query_options, + $join_conds + ); + + return $rows; + } + + protected function getDB() { + return wfGetDB( DB_REPLICA, 'recentchanges' ); + } + + public function outputFeedLinks() { + $this->addFeedLinks( $this->getFeedQuery() ); + } + + /** + * Get URL query parameters for action=feedrecentchanges API feed of current recent changes view. + * + * @return array + */ + protected function getFeedQuery() { + $query = array_filter( $this->getOptions()->getAllValues(), function ( $value ) { + // API handles empty parameters in a different way + return $value !== ''; + } ); + $query['action'] = 'feedrecentchanges'; + $feedLimit = $this->getConfig()->get( 'FeedLimit' ); + if ( $query['limit'] > $feedLimit ) { + $query['limit'] = $feedLimit; + } + + return $query; + } + + /** + * Build and output the actual changes list. + * + * @param IResultWrapper $rows Database rows + * @param FormOptions $opts + */ + public function outputChangesList( $rows, $opts ) { + $limit = $opts['limit']; + + $showWatcherCount = $this->getConfig()->get( 'RCShowWatchingUsers' ) + && $this->getUser()->getOption( 'shownumberswatching' ); + $watcherCache = []; + + $counter = 1; + $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups ); + $list->initChangesListRows( $rows ); + + $userShowHiddenCats = $this->getUser()->getBoolOption( 'showhiddencats' ); + $rclistOutput = $list->beginRecentChangesList(); + if ( $this->isStructuredFilterUiEnabled() ) { + $rclistOutput .= $this->makeLegend(); + } + + foreach ( $rows as $obj ) { + if ( $limit == 0 ) { + break; + } + $rc = RecentChange::newFromRow( $obj ); + + # Skip CatWatch entries for hidden cats based on user preference + if ( + $rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE && + !$userShowHiddenCats && + $rc->getParam( 'hidden-cat' ) + ) { + continue; + } + + $rc->counter = $counter++; + # Check if the page has been updated since the last visit + if ( $this->getConfig()->get( 'ShowUpdatedMarker' ) + && !empty( $obj->wl_notificationtimestamp ) + ) { + $rc->notificationtimestamp = ( $obj->rc_timestamp >= $obj->wl_notificationtimestamp ); + } else { + $rc->notificationtimestamp = false; // Default + } + # Check the number of users watching the page + $rc->numberofWatchingusers = 0; // Default + if ( $showWatcherCount && $obj->rc_namespace >= 0 ) { + if ( !isset( $watcherCache[$obj->rc_namespace][$obj->rc_title] ) ) { + $watcherCache[$obj->rc_namespace][$obj->rc_title] = + MediaWikiServices::getInstance()->getWatchedItemStore()->countWatchers( + new TitleValue( (int)$obj->rc_namespace, $obj->rc_title ) + ); + } + $rc->numberofWatchingusers = $watcherCache[$obj->rc_namespace][$obj->rc_title]; + } + + $changeLine = $list->recentChangesLine( $rc, !empty( $obj->wl_user ), $counter ); + if ( $changeLine !== false ) { + $rclistOutput .= $changeLine; + --$limit; + } + } + $rclistOutput .= $list->endRecentChangesList(); + + if ( $rows->numRows() === 0 ) { + $this->outputNoResults(); + if ( !$this->including() ) { + $this->getOutput()->setStatusCode( 404 ); + } + } else { + $this->getOutput()->addHTML( $rclistOutput ); + } + } + + /** + * Set the text to be displayed above the changes + * + * @param FormOptions $opts + * @param int $numRows Number of rows in the result to show after this header + */ + public function doHeader( $opts, $numRows ) { + $this->setTopText( $opts ); + + $defaults = $opts->getAllValues(); + $nondefaults = $opts->getChangedValues(); + + $panel = []; + if ( !$this->isStructuredFilterUiEnabled() ) { + $panel[] = $this->makeLegend(); + } + $panel[] = $this->optionsPanel( $defaults, $nondefaults, $numRows ); + $panel[] = '
'; + + $extraOpts = $this->getExtraOptions( $opts ); + $extraOptsCount = count( $extraOpts ); + $count = 0; + $submit = ' ' . Xml::submitButton( $this->msg( 'recentchanges-submit' )->text() ); + + $out = Xml::openElement( 'table', [ 'class' => 'mw-recentchanges-table' ] ); + foreach ( $extraOpts as $name => $optionRow ) { + # Add submit button to the last row only + ++$count; + $addSubmit = ( $count === $extraOptsCount ) ? $submit : ''; + + $out .= Xml::openElement( 'tr', [ 'class' => $name . 'Form' ] ); + if ( is_array( $optionRow ) ) { + $out .= Xml::tags( + 'td', + [ 'class' => 'mw-label mw-' . $name . '-label' ], + $optionRow[0] + ); + $out .= Xml::tags( + 'td', + [ 'class' => 'mw-input' ], + $optionRow[1] . $addSubmit + ); + } else { + $out .= Xml::tags( + 'td', + [ 'class' => 'mw-input', 'colspan' => 2 ], + $optionRow . $addSubmit + ); + } + $out .= Xml::closeElement( 'tr' ); + } + $out .= Xml::closeElement( 'table' ); + + $unconsumed = $opts->getUnconsumedValues(); + foreach ( $unconsumed as $key => $value ) { + $out .= Html::hidden( $key, $value ); + } + + $t = $this->getPageTitle(); + $out .= Html::hidden( 'title', $t->getPrefixedText() ); + $form = Xml::tags( 'form', [ 'action' => wfScript() ], $out ); + $panel[] = $form; + $panelString = implode( "\n", $panel ); + + $rcoptions = Xml::fieldset( + $this->msg( 'recentchanges-legend' )->text(), + $panelString, + [ 'class' => 'rcoptions cloptions' ] + ); + + // Insert a placeholder for RCFilters + if ( $this->isStructuredFilterUiEnabled() ) { + $rcfilterContainer = Html::element( + 'div', + [ 'class' => 'rcfilters-container' ] + ); + + $loadingContainer = Html::rawElement( + 'div', + [ 'class' => 'rcfilters-spinner' ], + Html::element( + 'div', + [ 'class' => 'rcfilters-spinner-bounce' ] + ) + ); + + // Wrap both with rcfilters-head + $this->getOutput()->addHTML( + Html::rawElement( + 'div', + [ 'class' => 'rcfilters-head' ], + $rcfilterContainer . $rcoptions + ) + ); + + // Add spinner + $this->getOutput()->addHTML( $loadingContainer ); + } else { + $this->getOutput()->addHTML( $rcoptions ); + } + + $this->setBottomText( $opts ); + } + + /** + * Send the text to be displayed above the options + * + * @param FormOptions $opts Unused + */ + function setTopText( FormOptions $opts ) { + $message = $this->msg( 'recentchangestext' )->inContentLanguage(); + if ( !$message->isDisabled() ) { + $contLang = MediaWikiServices::getInstance()->getContentLanguage(); + // Parse the message in this weird ugly way to preserve the ability to include interlanguage + // links in it (T172461). In the future when T66969 is resolved, perhaps we can just use + // $message->parse() instead. This code is copied from Message::parseText(). + $parserOutput = MessageCache::singleton()->parse( + $message->plain(), + $this->getPageTitle(), + /*linestart*/true, + // Message class sets the interface flag to false when parsing in a language different than + // user language, and this is wiki content language + /*interface*/false, + $contLang + ); + $content = $parserOutput->getText( [ + 'enableSectionEditLinks' => false, + ] ); + // Add only metadata here (including the language links), text is added below + $this->getOutput()->addParserOutputMetadata( $parserOutput ); + + $langAttributes = [ + 'lang' => $contLang->getHtmlCode(), + 'dir' => $contLang->getDir(), + ]; + + $topLinksAttributes = [ 'class' => 'mw-recentchanges-toplinks' ]; + + if ( $this->isStructuredFilterUiEnabled() ) { + // Check whether the widget is already collapsed or expanded + $collapsedState = $this->getRequest()->getCookie( 'rcfilters-toplinks-collapsed-state' ); + // Note that an empty/unset cookie means collapsed, so check for !== 'expanded' + $topLinksAttributes[ 'class' ] .= $collapsedState !== 'expanded' ? + ' mw-recentchanges-toplinks-collapsed' : ''; + + $this->getOutput()->enableOOUI(); + $contentTitle = new OOUI\ButtonWidget( [ + 'classes' => [ 'mw-recentchanges-toplinks-title' ], + 'label' => new OOUI\HtmlSnippet( $this->msg( 'rcfilters-other-review-tools' )->parse() ), + 'framed' => false, + 'indicator' => $collapsedState !== 'expanded' ? 'down' : 'up', + 'flags' => [ 'progressive' ], + ] ); + + $contentWrapper = Html::rawElement( 'div', + array_merge( + [ 'class' => 'mw-recentchanges-toplinks-content mw-collapsible-content' ], + $langAttributes + ), + $content + ); + $content = $contentTitle . $contentWrapper; + } else { + // Language direction should be on the top div only + // if the title is not there. If it is there, it's + // interface direction, and the language/dir attributes + // should be on the content itself + $topLinksAttributes = array_merge( $topLinksAttributes, $langAttributes ); + } + + $this->getOutput()->addHTML( + Html::rawElement( 'div', $topLinksAttributes, $content ) + ); + } + } + + /** + * Get options to be displayed in a form + * + * @param FormOptions $opts + * @return array + */ + function getExtraOptions( $opts ) { + $opts->consumeValues( [ + 'namespace', 'invert', 'associated', 'tagfilter' + ] ); + + $extraOpts = []; + $extraOpts['namespace'] = $this->namespaceFilterForm( $opts ); + + $tagFilter = ChangeTags::buildTagFilterSelector( + $opts['tagfilter'], false, $this->getContext() ); + if ( count( $tagFilter ) ) { + $extraOpts['tagfilter'] = $tagFilter; + } + + // Don't fire the hook for subclasses. (Or should we?) + if ( $this->getName() === 'Recentchanges' ) { + Hooks::run( 'SpecialRecentChangesPanel', [ &$extraOpts, $opts ] ); + } + + return $extraOpts; + } + + /** + * Add page-specific modules. + */ + protected function addModules() { + parent::addModules(); + $out = $this->getOutput(); + $out->addModules( 'mediawiki.special.recentchanges' ); + } + + /** + * Get last modified date, for client caching + * Don't use this if we are using the patrol feature, patrol changes don't + * update the timestamp + * + * @return string|bool + */ + public function checkLastModified() { + $dbr = $this->getDB(); + $lastmod = $dbr->selectField( 'recentchanges', 'MAX(rc_timestamp)', '', __METHOD__ ); + + return $lastmod; + } + + /** + * Creates the choose namespace selection + * + * @param FormOptions $opts + * @return string[] + */ + protected function namespaceFilterForm( FormOptions $opts ) { + $nsSelect = Html::namespaceSelector( + [ 'selected' => $opts['namespace'], 'all' => '', 'in-user-lang' => true ], + [ 'name' => 'namespace', 'id' => 'namespace' ] + ); + $nsLabel = Xml::label( $this->msg( 'namespace' )->text(), 'namespace' ); + $attribs = [ 'class' => [ 'mw-input-with-label' ] ]; + // Hide the checkboxes when the namespace filter is set to 'all'. + if ( $opts['namespace'] === '' ) { + $attribs['class'][] = 'mw-input-hidden'; + } + $invert = Html::rawElement( 'span', $attribs, Xml::checkLabel( + $this->msg( 'invert' )->text(), 'invert', 'nsinvert', + $opts['invert'], + [ 'title' => $this->msg( 'tooltip-invert' )->text() ] + ) ); + $associated = Html::rawElement( 'span', $attribs, Xml::checkLabel( + $this->msg( 'namespace_association' )->text(), 'associated', 'nsassociated', + $opts['associated'], + [ 'title' => $this->msg( 'tooltip-namespace_association' )->text() ] + ) ); + + return [ $nsLabel, "$nsSelect $invert $associated" ]; + } + + /** + * Filter $rows by categories set in $opts + * + * @deprecated since 1.31 + * + * @param IResultWrapper &$rows Database rows + * @param FormOptions $opts + */ + function filterByCategories( &$rows, FormOptions $opts ) { + wfDeprecated( __METHOD__, '1.31' ); + + $categories = array_map( 'trim', explode( '|', $opts['categories'] ) ); + + if ( $categories === [] ) { + return; + } + + # Filter categories + $cats = []; + foreach ( $categories as $cat ) { + $cat = trim( $cat ); + if ( $cat == '' ) { + continue; + } + $cats[] = $cat; + } + + # Filter articles + $articles = []; + $a2r = []; + $rowsarr = []; + foreach ( $rows as $k => $r ) { + $nt = Title::makeTitle( $r->rc_namespace, $r->rc_title ); + $id = $nt->getArticleID(); + if ( $id == 0 ) { + continue; # Page might have been deleted... + } + if ( !in_array( $id, $articles ) ) { + $articles[] = $id; + } + if ( !isset( $a2r[$id] ) ) { + $a2r[$id] = []; + } + $a2r[$id][] = $k; + $rowsarr[$k] = $r; + } + + # Shortcut? + if ( $articles === [] || $cats === [] ) { + return; + } + + # Look up + $catFind = new CategoryFinder; + $catFind->seed( $articles, $cats, $opts['categories_any'] ? 'OR' : 'AND' ); + $match = $catFind->run(); + + # Filter + $newrows = []; + foreach ( $match as $id ) { + foreach ( $a2r[$id] as $rev ) { + $k = $rev; + $newrows[$k] = $rowsarr[$k]; + } + } + $rows = new FakeResultWrapper( array_values( $newrows ) ); + } + + /** + * Makes change an option link which carries all the other options + * + * @param string $title + * @param array $override Options to override + * @param array $options Current options + * @param bool $active Whether to show the link in bold + * @return string + */ + function makeOptionsLink( $title, $override, $options, $active = false ) { + $params = $this->convertParamsForLink( $override + $options ); + + if ( $active ) { + $title = new HtmlArmor( '' . htmlspecialchars( $title ) . '' ); + } + + return $this->getLinkRenderer()->makeKnownLink( $this->getPageTitle(), $title, [ + 'data-params' => json_encode( $override ), + 'data-keys' => implode( ',', array_keys( $override ) ), + ], $params ); + } + + /** + * Creates the options panel. + * + * @param array $defaults + * @param array $nondefaults + * @param int $numRows Number of rows in the result to show after this header + * @return string + */ + function optionsPanel( $defaults, $nondefaults, $numRows ) { + $options = $nondefaults + $defaults; + + $note = ''; + $msg = $this->msg( 'rclegend' ); + if ( !$msg->isDisabled() ) { + $note .= Html::rawElement( + 'div', + [ 'class' => 'mw-rclegend' ], + $msg->parse() + ); + } + + $lang = $this->getLanguage(); + $user = $this->getUser(); + $config = $this->getConfig(); + if ( $options['from'] ) { + $resetLink = $this->makeOptionsLink( $this->msg( 'rclistfromreset' ), + [ 'from' => '' ], $nondefaults ); + + $noteFromMsg = $this->msg( 'rcnotefrom' ) + ->numParams( $options['limit'] ) + ->params( + $lang->userTimeAndDate( $options['from'], $user ), + $lang->userDate( $options['from'], $user ), + $lang->userTime( $options['from'], $user ) + ) + ->numParams( $numRows ); + $note .= Html::rawElement( + 'span', + [ 'class' => 'rcnotefrom' ], + $noteFromMsg->parse() + ) . + ' ' . + Html::rawElement( + 'span', + [ 'class' => 'rcoptions-listfromreset' ], + $this->msg( 'parentheses' )->rawParams( $resetLink )->parse() + ) . + '
'; + } + + # Sort data for display and make sure it's unique after we've added user data. + $linkLimits = $config->get( 'RCLinkLimits' ); + $linkLimits[] = $options['limit']; + sort( $linkLimits ); + $linkLimits = array_unique( $linkLimits ); + + $linkDays = $config->get( 'RCLinkDays' ); + $linkDays[] = $options['days']; + sort( $linkDays ); + $linkDays = array_unique( $linkDays ); + + // limit links + $cl = []; + foreach ( $linkLimits as $value ) { + $cl[] = $this->makeOptionsLink( $lang->formatNum( $value ), + [ 'limit' => $value ], $nondefaults, $value == $options['limit'] ); + } + $cl = $lang->pipeList( $cl ); + + // day links, reset 'from' to none + $dl = []; + foreach ( $linkDays as $value ) { + $dl[] = $this->makeOptionsLink( $lang->formatNum( $value ), + [ 'days' => $value, 'from' => '' ], $nondefaults, $value == $options['days'] ); + } + $dl = $lang->pipeList( $dl ); + + $showhide = [ 'show', 'hide' ]; + + $links = []; + + foreach ( $this->getLegacyShowHideFilters() as $key => $filter ) { + $msg = $filter->getShowHide(); + $linkMessage = $this->msg( $msg . '-' . $showhide[1 - $options[$key]] ); + // Extensions can define additional filters, but don't need to define the corresponding + // messages. If they don't exist, just fall back to 'show' and 'hide'. + if ( !$linkMessage->exists() ) { + $linkMessage = $this->msg( $showhide[1 - $options[$key]] ); + } + + $link = $this->makeOptionsLink( $linkMessage->text(), + [ $key => 1 - $options[$key] ], $nondefaults ); + + $attribs = [ + 'class' => "$msg rcshowhideoption clshowhideoption", + 'data-filter-name' => $filter->getName(), + ]; + + if ( $filter->isFeatureAvailableOnStructuredUi( $this ) ) { + $attribs['data-feature-in-structured-ui'] = true; + } + + $links[] = Html::rawElement( + 'span', + $attribs, + $this->msg( $msg )->rawParams( $link )->parse() + ); + } + + // show from this onward link + $timestamp = wfTimestampNow(); + $now = $lang->userTimeAndDate( $timestamp, $user ); + $timenow = $lang->userTime( $timestamp, $user ); + $datenow = $lang->userDate( $timestamp, $user ); + $pipedLinks = '' . $lang->pipeList( $links ) . ''; + + $rclinks = Html::rawElement( + 'span', + [ 'class' => 'rclinks' ], + $this->msg( 'rclinks' )->rawParams( $cl, $dl, '' )->parse() + ); + + $rclistfrom = Html::rawElement( + 'span', + [ 'class' => 'rclistfrom' ], + $this->makeOptionsLink( + $this->msg( 'rclistfrom' )->plaintextParams( $now, $timenow, $datenow )->parse(), + [ 'from' => $timestamp ], + $nondefaults + ) + ); + + return "{$note}$rclinks
$pipedLinks
$rclistfrom"; + } + + public function isIncludable() { + return true; + } + + protected function getCacheTTL() { + return 60 * 5; + } + + public function getDefaultLimit() { + $systemPrefValue = $this->getUser()->getIntOption( 'rclimit' ); + // Prefer the RCFilters-specific preference if RCFilters is enabled + if ( $this->isStructuredFilterUiEnabled() ) { + return $this->getUser()->getIntOption( static::$limitPreferenceName, $systemPrefValue ); + } + + // Otherwise, use the system rclimit preference value + return $systemPrefValue; + } +} diff --git a/includes/specials/SpecialRecentChangesLinked.php b/includes/specials/SpecialRecentChangesLinked.php new file mode 100644 index 0000000000..88656546e3 --- /dev/null +++ b/includes/specials/SpecialRecentChangesLinked.php @@ -0,0 +1,319 @@ +add( 'target', '' ); + $opts->add( 'showlinkedto', false ); + + return $opts; + } + + public function parseParameters( $par, FormOptions $opts ) { + $opts['target'] = $par; + } + + /** + * @inheritDoc + */ + protected function doMainQuery( $tables, $select, $conds, $query_options, + $join_conds, FormOptions $opts + ) { + $target = $opts['target']; + $showlinkedto = $opts['showlinkedto']; + $limit = $opts['limit']; + + if ( $target === '' ) { + return false; + } + $outputPage = $this->getOutput(); + $title = Title::newFromText( $target ); + if ( !$title || $title->isExternal() ) { + $outputPage->addHTML( + Html::errorBox( $this->msg( 'allpagesbadtitle' )->parse() ) + ); + return false; + } + + $outputPage->setPageTitle( $this->msg( 'recentchangeslinked-title', $title->getPrefixedText() ) ); + + /* + * Ordinary links are in the pagelinks table, while transclusions are + * in the templatelinks table, categorizations in categorylinks and + * image use in imagelinks. We need to somehow combine all these. + * Special:Whatlinkshere does this by firing multiple queries and + * merging the results, but the code we inherit from our parent class + * expects only one result set so we use UNION instead. + */ + + $dbr = wfGetDB( DB_REPLICA, 'recentchangeslinked' ); + $id = $title->getArticleID(); + $ns = $title->getNamespace(); + $dbkey = $title->getDBkey(); + + $rcQuery = RecentChange::getQueryInfo(); + $tables = array_merge( $tables, $rcQuery['tables'] ); + $select = array_merge( $rcQuery['fields'], $select ); + $join_conds = array_merge( $join_conds, $rcQuery['joins'] ); + + // left join with watchlist table to highlight watched rows + $uid = $this->getUser()->getId(); + if ( $uid && $this->getUser()->isAllowed( 'viewmywatchlist' ) ) { + $tables[] = 'watchlist'; + $select[] = 'wl_user'; + $join_conds['watchlist'] = [ 'LEFT JOIN', [ + 'wl_user' => $uid, + 'wl_title=rc_title', + 'wl_namespace=rc_namespace' + ] ]; + } + + // JOIN on page, used for 'last revision' filter highlight + $tables[] = 'page'; + $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ]; + $select[] = 'page_latest'; + + $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : []; + ChangeTags::modifyDisplayQuery( + $tables, + $select, + $conds, + $join_conds, + $query_options, + $tagFilter + ); + + if ( $dbr->unionSupportsOrderAndLimit() ) { + if ( count( $tagFilter ) > 1 ) { + // ChangeTags::modifyDisplayQuery() will have added DISTINCT. + // To prevent this from causing query performance problems, we need to add + // a GROUP BY, and add rc_id to the ORDER BY. + $order = [ + 'GROUP BY' => 'rc_timestamp, rc_id', + 'ORDER BY' => 'rc_timestamp DESC, rc_id DESC' + ]; + } else { + $order = [ 'ORDER BY' => 'rc_timestamp DESC' ]; + } + } else { + $order = []; + } + + if ( !$this->runMainQueryHook( $tables, $select, $conds, $query_options, $join_conds, + $opts ) + ) { + return false; + } + + if ( $ns == NS_CATEGORY && !$showlinkedto ) { + // special handling for categories + // XXX: should try to make this less kludgy + $link_tables = [ 'categorylinks' ]; + $showlinkedto = true; + } else { + // for now, always join on these tables; really should be configurable as in whatlinkshere + $link_tables = [ 'pagelinks', 'templatelinks' ]; + // imagelinks only contains links to pages in NS_FILE + if ( $ns == NS_FILE || !$showlinkedto ) { + $link_tables[] = 'imagelinks'; + } + } + + if ( $id == 0 && !$showlinkedto ) { + return false; // nonexistent pages can't link to any pages + } + + // field name prefixes for all the various tables we might want to join with + $prefix = [ + 'pagelinks' => 'pl', + 'templatelinks' => 'tl', + 'categorylinks' => 'cl', + 'imagelinks' => 'il' + ]; + + $subsql = []; // SELECT statements to combine with UNION + + foreach ( $link_tables as $link_table ) { + $pfx = $prefix[$link_table]; + + // imagelinks and categorylinks tables have no xx_namespace field, + // and have xx_to instead of xx_title + if ( $link_table == 'imagelinks' ) { + $link_ns = NS_FILE; + } elseif ( $link_table == 'categorylinks' ) { + $link_ns = NS_CATEGORY; + } else { + $link_ns = 0; + } + + if ( $showlinkedto ) { + // find changes to pages linking to this page + if ( $link_ns ) { + if ( $ns != $link_ns ) { + continue; + } // should never happen, but check anyway + $subconds = [ "{$pfx}_to" => $dbkey ]; + } else { + $subconds = [ "{$pfx}_namespace" => $ns, "{$pfx}_title" => $dbkey ]; + } + $subjoin = "rc_cur_id = {$pfx}_from"; + } else { + // find changes to pages linked from this page + $subconds = [ "{$pfx}_from" => $id ]; + if ( $link_table == 'imagelinks' || $link_table == 'categorylinks' ) { + $subconds["rc_namespace"] = $link_ns; + $subjoin = "rc_title = {$pfx}_to"; + } else { + $subjoin = [ "rc_namespace = {$pfx}_namespace", "rc_title = {$pfx}_title" ]; + } + } + + $query = $dbr->selectSQLText( + array_merge( $tables, [ $link_table ] ), + $select, + $conds + $subconds, + __METHOD__, + $order + $query_options, + $join_conds + [ $link_table => [ 'JOIN', $subjoin ] ] + ); + + if ( $dbr->unionSupportsOrderAndLimit() ) { + $query = $dbr->limitResult( $query, $limit ); + } + + $subsql[] = $query; + } + + if ( count( $subsql ) == 0 ) { + return false; // should never happen + } + if ( count( $subsql ) == 1 && $dbr->unionSupportsOrderAndLimit() ) { + $sql = $subsql[0]; + } else { + // need to resort and relimit after union + $sql = $dbr->unionQueries( $subsql, $dbr::UNION_DISTINCT ) . + ' ORDER BY rc_timestamp DESC'; + $sql = $dbr->limitResult( $sql, $limit, false ); + } + + $res = $dbr->query( $sql, __METHOD__ ); + + if ( $res->numRows() == 0 ) { + $this->mResultEmpty = true; + } + + return $res; + } + + function setTopText( FormOptions $opts ) { + $target = $this->getTargetTitle(); + if ( $target ) { + $this->getOutput()->addBacklinkSubtitle( $target ); + $this->getSkin()->setRelevantTitle( $target ); + } + } + + /** + * Get options to be displayed in a form + * + * @param FormOptions $opts + * @return array + */ + function getExtraOptions( $opts ) { + $extraOpts = parent::getExtraOptions( $opts ); + + $opts->consumeValues( [ 'showlinkedto', 'target' ] ); + + $extraOpts['target'] = [ $this->msg( 'recentchangeslinked-page' )->escaped(), + Xml::input( 'target', 40, str_replace( '_', ' ', $opts['target'] ) ) . + Xml::check( 'showlinkedto', $opts['showlinkedto'], [ 'id' => 'showlinkedto' ] ) . ' ' . + Xml::label( $this->msg( 'recentchangeslinked-to' )->text(), 'showlinkedto' ) ]; + + $this->addHelpLink( 'Help:Related changes' ); + return $extraOpts; + } + + /** + * @return Title + */ + function getTargetTitle() { + if ( $this->rclTargetTitle === null ) { + $opts = $this->getOptions(); + if ( isset( $opts['target'] ) && $opts['target'] !== '' ) { + $this->rclTargetTitle = Title::newFromText( $opts['target'] ); + } else { + $this->rclTargetTitle = false; + } + } + + return $this->rclTargetTitle; + } + + /** + * Return an array of subpages beginning with $search that this special page will accept. + * + * @param string $search Prefix to search for + * @param int $limit Maximum number of results to return (usually 10) + * @param int $offset Number of results to skip (usually 0) + * @return string[] Matching subpages + */ + public function prefixSearchSubpages( $search, $limit, $offset ) { + return $this->prefixSearchString( $search, $limit, $offset ); + } + + protected function outputNoResults() { + $targetTitle = $this->getTargetTitle(); + if ( $targetTitle === false ) { + $this->getOutput()->addHTML( + Html::rawElement( + 'div', + [ 'class' => 'mw-changeslist-empty mw-changeslist-notargetpage' ], + $this->msg( 'recentchanges-notargetpage' )->parse() + ) + ); + } elseif ( !$targetTitle || $targetTitle->isExternal() ) { + $this->getOutput()->addHTML( + Html::rawElement( + 'div', + [ 'class' => 'mw-changeslist-empty mw-changeslist-invalidtargetpage' ], + $this->msg( 'allpagesbadtitle' )->parse() + ) + ); + } else { + parent::outputNoResults(); + } + } +} diff --git a/includes/specials/SpecialRecentchanges.php b/includes/specials/SpecialRecentchanges.php deleted file mode 100644 index c8f65c1bcb..0000000000 --- a/includes/specials/SpecialRecentchanges.php +++ /dev/null @@ -1,945 +0,0 @@ -watchlistFilterGroupDefinition = [ - 'name' => 'watchlist', - 'title' => 'rcfilters-filtergroup-watchlist', - 'class' => ChangesListStringOptionsFilterGroup::class, - 'priority' => -9, - 'isFullCoverage' => true, - 'filters' => [ - [ - 'name' => 'watched', - 'label' => 'rcfilters-filter-watchlist-watched-label', - 'description' => 'rcfilters-filter-watchlist-watched-description', - 'cssClassSuffix' => 'watched', - 'isRowApplicableCallable' => function ( $ctx, $rc ) { - return $rc->getAttribute( 'wl_user' ); - } - ], - [ - 'name' => 'watchednew', - 'label' => 'rcfilters-filter-watchlist-watchednew-label', - 'description' => 'rcfilters-filter-watchlist-watchednew-description', - 'cssClassSuffix' => 'watchednew', - 'isRowApplicableCallable' => function ( $ctx, $rc ) { - return $rc->getAttribute( 'wl_user' ) && - $rc->getAttribute( 'rc_timestamp' ) && - $rc->getAttribute( 'wl_notificationtimestamp' ) && - $rc->getAttribute( 'rc_timestamp' ) >= $rc->getAttribute( 'wl_notificationtimestamp' ); - }, - ], - [ - 'name' => 'notwatched', - 'label' => 'rcfilters-filter-watchlist-notwatched-label', - 'description' => 'rcfilters-filter-watchlist-notwatched-description', - 'cssClassSuffix' => 'notwatched', - 'isRowApplicableCallable' => function ( $ctx, $rc ) { - return $rc->getAttribute( 'wl_user' ) === null; - }, - ] - ], - 'default' => ChangesListStringOptionsFilterGroup::NONE, - 'queryCallable' => function ( $specialPageClassName, $context, $dbr, - &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues ) { - sort( $selectedValues ); - $notwatchedCond = 'wl_user IS NULL'; - $watchedCond = 'wl_user IS NOT NULL'; - $newCond = 'rc_timestamp >= wl_notificationtimestamp'; - - if ( $selectedValues === [ 'notwatched' ] ) { - $conds[] = $notwatchedCond; - return; - } - - if ( $selectedValues === [ 'watched' ] ) { - $conds[] = $watchedCond; - return; - } - - if ( $selectedValues === [ 'watchednew' ] ) { - $conds[] = $dbr->makeList( [ - $watchedCond, - $newCond - ], LIST_AND ); - return; - } - - if ( $selectedValues === [ 'notwatched', 'watched' ] ) { - // no filters - return; - } - - if ( $selectedValues === [ 'notwatched', 'watchednew' ] ) { - $conds[] = $dbr->makeList( [ - $notwatchedCond, - $dbr->makeList( [ - $watchedCond, - $newCond - ], LIST_AND ) - ], LIST_OR ); - return; - } - - if ( $selectedValues === [ 'watched', 'watchednew' ] ) { - $conds[] = $watchedCond; - return; - } - - if ( $selectedValues === [ 'notwatched', 'watched', 'watchednew' ] ) { - // no filters - return; - } - } - ]; - } - - /** - * @param string|null $subpage - */ - public function execute( $subpage ) { - // Backwards-compatibility: redirect to new feed URLs - $feedFormat = $this->getRequest()->getVal( 'feed' ); - if ( !$this->including() && $feedFormat ) { - $query = $this->getFeedQuery(); - $query['feedformat'] = $feedFormat === 'atom' ? 'atom' : 'rss'; - $this->getOutput()->redirect( wfAppendQuery( wfScript( 'api' ), $query ) ); - - return; - } - - // 10 seconds server-side caching max - $out = $this->getOutput(); - $out->setCdnMaxage( 10 ); - // Check if the client has a cached version - $lastmod = $this->checkLastModified(); - if ( $lastmod === false ) { - return; - } - - $this->addHelpLink( - '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:Recent_changes', - true - ); - parent::execute( $subpage ); - } - - /** - * @inheritDoc - */ - protected function transformFilterDefinition( array $filterDefinition ) { - if ( isset( $filterDefinition['showHideSuffix'] ) ) { - $filterDefinition['showHide'] = 'rc' . $filterDefinition['showHideSuffix']; - } - - return $filterDefinition; - } - - /** - * @inheritDoc - */ - protected function registerFilters() { - parent::registerFilters(); - - if ( - !$this->including() && - $this->getUser()->isLoggedIn() && - $this->getUser()->isAllowed( 'viewmywatchlist' ) - ) { - $this->registerFiltersFromDefinitions( [ $this->watchlistFilterGroupDefinition ] ); - $watchlistGroup = $this->getFilterGroup( 'watchlist' ); - $watchlistGroup->getFilter( 'watched' )->setAsSupersetOf( - $watchlistGroup->getFilter( 'watchednew' ) - ); - } - - $user = $this->getUser(); - - $significance = $this->getFilterGroup( 'significance' ); - $hideMinor = $significance->getFilter( 'hideminor' ); - $hideMinor->setDefault( $user->getBoolOption( 'hideminor' ) ); - - $automated = $this->getFilterGroup( 'automated' ); - $hideBots = $automated->getFilter( 'hidebots' ); - $hideBots->setDefault( true ); - - $reviewStatus = $this->getFilterGroup( 'reviewStatus' ); - if ( $reviewStatus !== null ) { - // Conditional on feature being available and rights - if ( $user->getBoolOption( 'hidepatrolled' ) ) { - $reviewStatus->setDefault( 'unpatrolled' ); - $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' ); - $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' ); - $legacyHidePatrolled->setDefault( true ); - } - } - - $changeType = $this->getFilterGroup( 'changeType' ); - $hideCategorization = $changeType->getFilter( 'hidecategorization' ); - if ( $hideCategorization !== null ) { - // Conditional on feature being available - $hideCategorization->setDefault( $user->getBoolOption( 'hidecategorization' ) ); - } - } - - /** - * Process $par and put options found in $opts. Used when including the page. - * - * @param string $par - * @param FormOptions $opts - */ - public function parseParameters( $par, FormOptions $opts ) { - parent::parseParameters( $par, $opts ); - - $bits = preg_split( '/\s*,\s*/', trim( $par ) ); - foreach ( $bits as $bit ) { - if ( is_numeric( $bit ) ) { - $opts['limit'] = $bit; - } - - $m = []; - if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) { - $opts['limit'] = $m[1]; - } - if ( preg_match( '/^days=(\d+(?:\.\d+)?)$/', $bit, $m ) ) { - $opts['days'] = $m[1]; - } - if ( preg_match( '/^namespace=(.*)$/', $bit, $m ) ) { - $opts['namespace'] = $m[1]; - } - if ( preg_match( '/^tagfilter=(.*)$/', $bit, $m ) ) { - $opts['tagfilter'] = $m[1]; - } - } - } - - /** - * @inheritDoc - */ - protected function doMainQuery( $tables, $fields, $conds, $query_options, - $join_conds, FormOptions $opts - ) { - $dbr = $this->getDB(); - $user = $this->getUser(); - - $rcQuery = RecentChange::getQueryInfo(); - $tables = array_merge( $tables, $rcQuery['tables'] ); - $fields = array_merge( $rcQuery['fields'], $fields ); - $join_conds = array_merge( $join_conds, $rcQuery['joins'] ); - - // JOIN on watchlist for users - if ( $user->isLoggedIn() && $user->isAllowed( 'viewmywatchlist' ) ) { - $tables[] = 'watchlist'; - $fields[] = 'wl_user'; - $fields[] = 'wl_notificationtimestamp'; - $join_conds['watchlist'] = [ 'LEFT JOIN', [ - 'wl_user' => $user->getId(), - 'wl_title=rc_title', - 'wl_namespace=rc_namespace' - ] ]; - } - - // JOIN on page, used for 'last revision' filter highlight - $tables[] = 'page'; - $fields[] = 'page_latest'; - $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ]; - - $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : []; - ChangeTags::modifyDisplayQuery( - $tables, - $fields, - $conds, - $join_conds, - $query_options, - $tagFilter - ); - - if ( !$this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, - $opts ) - ) { - return false; - } - - if ( $this->areFiltersInConflict() ) { - return false; - } - - $orderByAndLimit = [ - 'ORDER BY' => 'rc_timestamp DESC', - 'LIMIT' => $opts['limit'] - ]; - if ( in_array( 'DISTINCT', $query_options ) ) { - // ChangeTags::modifyDisplayQuery() adds DISTINCT when filtering on multiple tags. - // In order to prevent DISTINCT from causing query performance problems, - // we have to GROUP BY the primary key. This in turn requires us to add - // the primary key to the end of the ORDER BY, and the old ORDER BY to the - // start of the GROUP BY - $orderByAndLimit['ORDER BY'] = 'rc_timestamp DESC, rc_id DESC'; - $orderByAndLimit['GROUP BY'] = 'rc_timestamp, rc_id'; - } - // array_merge() is used intentionally here so that hooks can, should - // they so desire, override the ORDER BY / LIMIT condition(s); prior to - // MediaWiki 1.26 this used to use the plus operator instead, which meant - // that extensions weren't able to change these conditions - $query_options = array_merge( $orderByAndLimit, $query_options ); - $rows = $dbr->select( - $tables, - $fields, - // rc_new is not an ENUM, but adding a redundant rc_new IN (0,1) gives mysql enough - // knowledge to use an index merge if it wants (it may use some other index though). - $conds + [ 'rc_new' => [ 0, 1 ] ], - __METHOD__, - $query_options, - $join_conds - ); - - return $rows; - } - - protected function getDB() { - return wfGetDB( DB_REPLICA, 'recentchanges' ); - } - - public function outputFeedLinks() { - $this->addFeedLinks( $this->getFeedQuery() ); - } - - /** - * Get URL query parameters for action=feedrecentchanges API feed of current recent changes view. - * - * @return array - */ - protected function getFeedQuery() { - $query = array_filter( $this->getOptions()->getAllValues(), function ( $value ) { - // API handles empty parameters in a different way - return $value !== ''; - } ); - $query['action'] = 'feedrecentchanges'; - $feedLimit = $this->getConfig()->get( 'FeedLimit' ); - if ( $query['limit'] > $feedLimit ) { - $query['limit'] = $feedLimit; - } - - return $query; - } - - /** - * Build and output the actual changes list. - * - * @param IResultWrapper $rows Database rows - * @param FormOptions $opts - */ - public function outputChangesList( $rows, $opts ) { - $limit = $opts['limit']; - - $showWatcherCount = $this->getConfig()->get( 'RCShowWatchingUsers' ) - && $this->getUser()->getOption( 'shownumberswatching' ); - $watcherCache = []; - - $counter = 1; - $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups ); - $list->initChangesListRows( $rows ); - - $userShowHiddenCats = $this->getUser()->getBoolOption( 'showhiddencats' ); - $rclistOutput = $list->beginRecentChangesList(); - if ( $this->isStructuredFilterUiEnabled() ) { - $rclistOutput .= $this->makeLegend(); - } - - foreach ( $rows as $obj ) { - if ( $limit == 0 ) { - break; - } - $rc = RecentChange::newFromRow( $obj ); - - # Skip CatWatch entries for hidden cats based on user preference - if ( - $rc->getAttribute( 'rc_type' ) == RC_CATEGORIZE && - !$userShowHiddenCats && - $rc->getParam( 'hidden-cat' ) - ) { - continue; - } - - $rc->counter = $counter++; - # Check if the page has been updated since the last visit - if ( $this->getConfig()->get( 'ShowUpdatedMarker' ) - && !empty( $obj->wl_notificationtimestamp ) - ) { - $rc->notificationtimestamp = ( $obj->rc_timestamp >= $obj->wl_notificationtimestamp ); - } else { - $rc->notificationtimestamp = false; // Default - } - # Check the number of users watching the page - $rc->numberofWatchingusers = 0; // Default - if ( $showWatcherCount && $obj->rc_namespace >= 0 ) { - if ( !isset( $watcherCache[$obj->rc_namespace][$obj->rc_title] ) ) { - $watcherCache[$obj->rc_namespace][$obj->rc_title] = - MediaWikiServices::getInstance()->getWatchedItemStore()->countWatchers( - new TitleValue( (int)$obj->rc_namespace, $obj->rc_title ) - ); - } - $rc->numberofWatchingusers = $watcherCache[$obj->rc_namespace][$obj->rc_title]; - } - - $changeLine = $list->recentChangesLine( $rc, !empty( $obj->wl_user ), $counter ); - if ( $changeLine !== false ) { - $rclistOutput .= $changeLine; - --$limit; - } - } - $rclistOutput .= $list->endRecentChangesList(); - - if ( $rows->numRows() === 0 ) { - $this->outputNoResults(); - if ( !$this->including() ) { - $this->getOutput()->setStatusCode( 404 ); - } - } else { - $this->getOutput()->addHTML( $rclistOutput ); - } - } - - /** - * Set the text to be displayed above the changes - * - * @param FormOptions $opts - * @param int $numRows Number of rows in the result to show after this header - */ - public function doHeader( $opts, $numRows ) { - $this->setTopText( $opts ); - - $defaults = $opts->getAllValues(); - $nondefaults = $opts->getChangedValues(); - - $panel = []; - if ( !$this->isStructuredFilterUiEnabled() ) { - $panel[] = $this->makeLegend(); - } - $panel[] = $this->optionsPanel( $defaults, $nondefaults, $numRows ); - $panel[] = '
'; - - $extraOpts = $this->getExtraOptions( $opts ); - $extraOptsCount = count( $extraOpts ); - $count = 0; - $submit = ' ' . Xml::submitButton( $this->msg( 'recentchanges-submit' )->text() ); - - $out = Xml::openElement( 'table', [ 'class' => 'mw-recentchanges-table' ] ); - foreach ( $extraOpts as $name => $optionRow ) { - # Add submit button to the last row only - ++$count; - $addSubmit = ( $count === $extraOptsCount ) ? $submit : ''; - - $out .= Xml::openElement( 'tr', [ 'class' => $name . 'Form' ] ); - if ( is_array( $optionRow ) ) { - $out .= Xml::tags( - 'td', - [ 'class' => 'mw-label mw-' . $name . '-label' ], - $optionRow[0] - ); - $out .= Xml::tags( - 'td', - [ 'class' => 'mw-input' ], - $optionRow[1] . $addSubmit - ); - } else { - $out .= Xml::tags( - 'td', - [ 'class' => 'mw-input', 'colspan' => 2 ], - $optionRow . $addSubmit - ); - } - $out .= Xml::closeElement( 'tr' ); - } - $out .= Xml::closeElement( 'table' ); - - $unconsumed = $opts->getUnconsumedValues(); - foreach ( $unconsumed as $key => $value ) { - $out .= Html::hidden( $key, $value ); - } - - $t = $this->getPageTitle(); - $out .= Html::hidden( 'title', $t->getPrefixedText() ); - $form = Xml::tags( 'form', [ 'action' => wfScript() ], $out ); - $panel[] = $form; - $panelString = implode( "\n", $panel ); - - $rcoptions = Xml::fieldset( - $this->msg( 'recentchanges-legend' )->text(), - $panelString, - [ 'class' => 'rcoptions cloptions' ] - ); - - // Insert a placeholder for RCFilters - if ( $this->isStructuredFilterUiEnabled() ) { - $rcfilterContainer = Html::element( - 'div', - [ 'class' => 'rcfilters-container' ] - ); - - $loadingContainer = Html::rawElement( - 'div', - [ 'class' => 'rcfilters-spinner' ], - Html::element( - 'div', - [ 'class' => 'rcfilters-spinner-bounce' ] - ) - ); - - // Wrap both with rcfilters-head - $this->getOutput()->addHTML( - Html::rawElement( - 'div', - [ 'class' => 'rcfilters-head' ], - $rcfilterContainer . $rcoptions - ) - ); - - // Add spinner - $this->getOutput()->addHTML( $loadingContainer ); - } else { - $this->getOutput()->addHTML( $rcoptions ); - } - - $this->setBottomText( $opts ); - } - - /** - * Send the text to be displayed above the options - * - * @param FormOptions $opts Unused - */ - function setTopText( FormOptions $opts ) { - $message = $this->msg( 'recentchangestext' )->inContentLanguage(); - if ( !$message->isDisabled() ) { - $contLang = MediaWikiServices::getInstance()->getContentLanguage(); - // Parse the message in this weird ugly way to preserve the ability to include interlanguage - // links in it (T172461). In the future when T66969 is resolved, perhaps we can just use - // $message->parse() instead. This code is copied from Message::parseText(). - $parserOutput = MessageCache::singleton()->parse( - $message->plain(), - $this->getPageTitle(), - /*linestart*/true, - // Message class sets the interface flag to false when parsing in a language different than - // user language, and this is wiki content language - /*interface*/false, - $contLang - ); - $content = $parserOutput->getText( [ - 'enableSectionEditLinks' => false, - ] ); - // Add only metadata here (including the language links), text is added below - $this->getOutput()->addParserOutputMetadata( $parserOutput ); - - $langAttributes = [ - 'lang' => $contLang->getHtmlCode(), - 'dir' => $contLang->getDir(), - ]; - - $topLinksAttributes = [ 'class' => 'mw-recentchanges-toplinks' ]; - - if ( $this->isStructuredFilterUiEnabled() ) { - // Check whether the widget is already collapsed or expanded - $collapsedState = $this->getRequest()->getCookie( 'rcfilters-toplinks-collapsed-state' ); - // Note that an empty/unset cookie means collapsed, so check for !== 'expanded' - $topLinksAttributes[ 'class' ] .= $collapsedState !== 'expanded' ? - ' mw-recentchanges-toplinks-collapsed' : ''; - - $this->getOutput()->enableOOUI(); - $contentTitle = new OOUI\ButtonWidget( [ - 'classes' => [ 'mw-recentchanges-toplinks-title' ], - 'label' => new OOUI\HtmlSnippet( $this->msg( 'rcfilters-other-review-tools' )->parse() ), - 'framed' => false, - 'indicator' => $collapsedState !== 'expanded' ? 'down' : 'up', - 'flags' => [ 'progressive' ], - ] ); - - $contentWrapper = Html::rawElement( 'div', - array_merge( - [ 'class' => 'mw-recentchanges-toplinks-content mw-collapsible-content' ], - $langAttributes - ), - $content - ); - $content = $contentTitle . $contentWrapper; - } else { - // Language direction should be on the top div only - // if the title is not there. If it is there, it's - // interface direction, and the language/dir attributes - // should be on the content itself - $topLinksAttributes = array_merge( $topLinksAttributes, $langAttributes ); - } - - $this->getOutput()->addHTML( - Html::rawElement( 'div', $topLinksAttributes, $content ) - ); - } - } - - /** - * Get options to be displayed in a form - * - * @param FormOptions $opts - * @return array - */ - function getExtraOptions( $opts ) { - $opts->consumeValues( [ - 'namespace', 'invert', 'associated', 'tagfilter' - ] ); - - $extraOpts = []; - $extraOpts['namespace'] = $this->namespaceFilterForm( $opts ); - - $tagFilter = ChangeTags::buildTagFilterSelector( - $opts['tagfilter'], false, $this->getContext() ); - if ( count( $tagFilter ) ) { - $extraOpts['tagfilter'] = $tagFilter; - } - - // Don't fire the hook for subclasses. (Or should we?) - if ( $this->getName() === 'Recentchanges' ) { - Hooks::run( 'SpecialRecentChangesPanel', [ &$extraOpts, $opts ] ); - } - - return $extraOpts; - } - - /** - * Add page-specific modules. - */ - protected function addModules() { - parent::addModules(); - $out = $this->getOutput(); - $out->addModules( 'mediawiki.special.recentchanges' ); - } - - /** - * Get last modified date, for client caching - * Don't use this if we are using the patrol feature, patrol changes don't - * update the timestamp - * - * @return string|bool - */ - public function checkLastModified() { - $dbr = $this->getDB(); - $lastmod = $dbr->selectField( 'recentchanges', 'MAX(rc_timestamp)', '', __METHOD__ ); - - return $lastmod; - } - - /** - * Creates the choose namespace selection - * - * @param FormOptions $opts - * @return string[] - */ - protected function namespaceFilterForm( FormOptions $opts ) { - $nsSelect = Html::namespaceSelector( - [ 'selected' => $opts['namespace'], 'all' => '', 'in-user-lang' => true ], - [ 'name' => 'namespace', 'id' => 'namespace' ] - ); - $nsLabel = Xml::label( $this->msg( 'namespace' )->text(), 'namespace' ); - $attribs = [ 'class' => [ 'mw-input-with-label' ] ]; - // Hide the checkboxes when the namespace filter is set to 'all'. - if ( $opts['namespace'] === '' ) { - $attribs['class'][] = 'mw-input-hidden'; - } - $invert = Html::rawElement( 'span', $attribs, Xml::checkLabel( - $this->msg( 'invert' )->text(), 'invert', 'nsinvert', - $opts['invert'], - [ 'title' => $this->msg( 'tooltip-invert' )->text() ] - ) ); - $associated = Html::rawElement( 'span', $attribs, Xml::checkLabel( - $this->msg( 'namespace_association' )->text(), 'associated', 'nsassociated', - $opts['associated'], - [ 'title' => $this->msg( 'tooltip-namespace_association' )->text() ] - ) ); - - return [ $nsLabel, "$nsSelect $invert $associated" ]; - } - - /** - * Filter $rows by categories set in $opts - * - * @deprecated since 1.31 - * - * @param IResultWrapper &$rows Database rows - * @param FormOptions $opts - */ - function filterByCategories( &$rows, FormOptions $opts ) { - wfDeprecated( __METHOD__, '1.31' ); - - $categories = array_map( 'trim', explode( '|', $opts['categories'] ) ); - - if ( $categories === [] ) { - return; - } - - # Filter categories - $cats = []; - foreach ( $categories as $cat ) { - $cat = trim( $cat ); - if ( $cat == '' ) { - continue; - } - $cats[] = $cat; - } - - # Filter articles - $articles = []; - $a2r = []; - $rowsarr = []; - foreach ( $rows as $k => $r ) { - $nt = Title::makeTitle( $r->rc_namespace, $r->rc_title ); - $id = $nt->getArticleID(); - if ( $id == 0 ) { - continue; # Page might have been deleted... - } - if ( !in_array( $id, $articles ) ) { - $articles[] = $id; - } - if ( !isset( $a2r[$id] ) ) { - $a2r[$id] = []; - } - $a2r[$id][] = $k; - $rowsarr[$k] = $r; - } - - # Shortcut? - if ( $articles === [] || $cats === [] ) { - return; - } - - # Look up - $catFind = new CategoryFinder; - $catFind->seed( $articles, $cats, $opts['categories_any'] ? 'OR' : 'AND' ); - $match = $catFind->run(); - - # Filter - $newrows = []; - foreach ( $match as $id ) { - foreach ( $a2r[$id] as $rev ) { - $k = $rev; - $newrows[$k] = $rowsarr[$k]; - } - } - $rows = new FakeResultWrapper( array_values( $newrows ) ); - } - - /** - * Makes change an option link which carries all the other options - * - * @param string $title - * @param array $override Options to override - * @param array $options Current options - * @param bool $active Whether to show the link in bold - * @return string - */ - function makeOptionsLink( $title, $override, $options, $active = false ) { - $params = $this->convertParamsForLink( $override + $options ); - - if ( $active ) { - $title = new HtmlArmor( '' . htmlspecialchars( $title ) . '' ); - } - - return $this->getLinkRenderer()->makeKnownLink( $this->getPageTitle(), $title, [ - 'data-params' => json_encode( $override ), - 'data-keys' => implode( ',', array_keys( $override ) ), - ], $params ); - } - - /** - * Creates the options panel. - * - * @param array $defaults - * @param array $nondefaults - * @param int $numRows Number of rows in the result to show after this header - * @return string - */ - function optionsPanel( $defaults, $nondefaults, $numRows ) { - $options = $nondefaults + $defaults; - - $note = ''; - $msg = $this->msg( 'rclegend' ); - if ( !$msg->isDisabled() ) { - $note .= Html::rawElement( - 'div', - [ 'class' => 'mw-rclegend' ], - $msg->parse() - ); - } - - $lang = $this->getLanguage(); - $user = $this->getUser(); - $config = $this->getConfig(); - if ( $options['from'] ) { - $resetLink = $this->makeOptionsLink( $this->msg( 'rclistfromreset' ), - [ 'from' => '' ], $nondefaults ); - - $noteFromMsg = $this->msg( 'rcnotefrom' ) - ->numParams( $options['limit'] ) - ->params( - $lang->userTimeAndDate( $options['from'], $user ), - $lang->userDate( $options['from'], $user ), - $lang->userTime( $options['from'], $user ) - ) - ->numParams( $numRows ); - $note .= Html::rawElement( - 'span', - [ 'class' => 'rcnotefrom' ], - $noteFromMsg->parse() - ) . - ' ' . - Html::rawElement( - 'span', - [ 'class' => 'rcoptions-listfromreset' ], - $this->msg( 'parentheses' )->rawParams( $resetLink )->parse() - ) . - '
'; - } - - # Sort data for display and make sure it's unique after we've added user data. - $linkLimits = $config->get( 'RCLinkLimits' ); - $linkLimits[] = $options['limit']; - sort( $linkLimits ); - $linkLimits = array_unique( $linkLimits ); - - $linkDays = $config->get( 'RCLinkDays' ); - $linkDays[] = $options['days']; - sort( $linkDays ); - $linkDays = array_unique( $linkDays ); - - // limit links - $cl = []; - foreach ( $linkLimits as $value ) { - $cl[] = $this->makeOptionsLink( $lang->formatNum( $value ), - [ 'limit' => $value ], $nondefaults, $value == $options['limit'] ); - } - $cl = $lang->pipeList( $cl ); - - // day links, reset 'from' to none - $dl = []; - foreach ( $linkDays as $value ) { - $dl[] = $this->makeOptionsLink( $lang->formatNum( $value ), - [ 'days' => $value, 'from' => '' ], $nondefaults, $value == $options['days'] ); - } - $dl = $lang->pipeList( $dl ); - - $showhide = [ 'show', 'hide' ]; - - $links = []; - - foreach ( $this->getLegacyShowHideFilters() as $key => $filter ) { - $msg = $filter->getShowHide(); - $linkMessage = $this->msg( $msg . '-' . $showhide[1 - $options[$key]] ); - // Extensions can define additional filters, but don't need to define the corresponding - // messages. If they don't exist, just fall back to 'show' and 'hide'. - if ( !$linkMessage->exists() ) { - $linkMessage = $this->msg( $showhide[1 - $options[$key]] ); - } - - $link = $this->makeOptionsLink( $linkMessage->text(), - [ $key => 1 - $options[$key] ], $nondefaults ); - - $attribs = [ - 'class' => "$msg rcshowhideoption clshowhideoption", - 'data-filter-name' => $filter->getName(), - ]; - - if ( $filter->isFeatureAvailableOnStructuredUi( $this ) ) { - $attribs['data-feature-in-structured-ui'] = true; - } - - $links[] = Html::rawElement( - 'span', - $attribs, - $this->msg( $msg )->rawParams( $link )->parse() - ); - } - - // show from this onward link - $timestamp = wfTimestampNow(); - $now = $lang->userTimeAndDate( $timestamp, $user ); - $timenow = $lang->userTime( $timestamp, $user ); - $datenow = $lang->userDate( $timestamp, $user ); - $pipedLinks = '' . $lang->pipeList( $links ) . ''; - - $rclinks = Html::rawElement( - 'span', - [ 'class' => 'rclinks' ], - $this->msg( 'rclinks' )->rawParams( $cl, $dl, '' )->parse() - ); - - $rclistfrom = Html::rawElement( - 'span', - [ 'class' => 'rclistfrom' ], - $this->makeOptionsLink( - $this->msg( 'rclistfrom' )->plaintextParams( $now, $timenow, $datenow )->parse(), - [ 'from' => $timestamp ], - $nondefaults - ) - ); - - return "{$note}$rclinks
$pipedLinks
$rclistfrom"; - } - - public function isIncludable() { - return true; - } - - protected function getCacheTTL() { - return 60 * 5; - } - - public function getDefaultLimit() { - $systemPrefValue = $this->getUser()->getIntOption( 'rclimit' ); - // Prefer the RCFilters-specific preference if RCFilters is enabled - if ( $this->isStructuredFilterUiEnabled() ) { - return $this->getUser()->getIntOption( static::$limitPreferenceName, $systemPrefValue ); - } - - // Otherwise, use the system rclimit preference value - return $systemPrefValue; - } -} diff --git a/includes/specials/SpecialRecentchangeslinked.php b/includes/specials/SpecialRecentchangeslinked.php deleted file mode 100644 index 88656546e3..0000000000 --- a/includes/specials/SpecialRecentchangeslinked.php +++ /dev/null @@ -1,319 +0,0 @@ -add( 'target', '' ); - $opts->add( 'showlinkedto', false ); - - return $opts; - } - - public function parseParameters( $par, FormOptions $opts ) { - $opts['target'] = $par; - } - - /** - * @inheritDoc - */ - protected function doMainQuery( $tables, $select, $conds, $query_options, - $join_conds, FormOptions $opts - ) { - $target = $opts['target']; - $showlinkedto = $opts['showlinkedto']; - $limit = $opts['limit']; - - if ( $target === '' ) { - return false; - } - $outputPage = $this->getOutput(); - $title = Title::newFromText( $target ); - if ( !$title || $title->isExternal() ) { - $outputPage->addHTML( - Html::errorBox( $this->msg( 'allpagesbadtitle' )->parse() ) - ); - return false; - } - - $outputPage->setPageTitle( $this->msg( 'recentchangeslinked-title', $title->getPrefixedText() ) ); - - /* - * Ordinary links are in the pagelinks table, while transclusions are - * in the templatelinks table, categorizations in categorylinks and - * image use in imagelinks. We need to somehow combine all these. - * Special:Whatlinkshere does this by firing multiple queries and - * merging the results, but the code we inherit from our parent class - * expects only one result set so we use UNION instead. - */ - - $dbr = wfGetDB( DB_REPLICA, 'recentchangeslinked' ); - $id = $title->getArticleID(); - $ns = $title->getNamespace(); - $dbkey = $title->getDBkey(); - - $rcQuery = RecentChange::getQueryInfo(); - $tables = array_merge( $tables, $rcQuery['tables'] ); - $select = array_merge( $rcQuery['fields'], $select ); - $join_conds = array_merge( $join_conds, $rcQuery['joins'] ); - - // left join with watchlist table to highlight watched rows - $uid = $this->getUser()->getId(); - if ( $uid && $this->getUser()->isAllowed( 'viewmywatchlist' ) ) { - $tables[] = 'watchlist'; - $select[] = 'wl_user'; - $join_conds['watchlist'] = [ 'LEFT JOIN', [ - 'wl_user' => $uid, - 'wl_title=rc_title', - 'wl_namespace=rc_namespace' - ] ]; - } - - // JOIN on page, used for 'last revision' filter highlight - $tables[] = 'page'; - $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ]; - $select[] = 'page_latest'; - - $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : []; - ChangeTags::modifyDisplayQuery( - $tables, - $select, - $conds, - $join_conds, - $query_options, - $tagFilter - ); - - if ( $dbr->unionSupportsOrderAndLimit() ) { - if ( count( $tagFilter ) > 1 ) { - // ChangeTags::modifyDisplayQuery() will have added DISTINCT. - // To prevent this from causing query performance problems, we need to add - // a GROUP BY, and add rc_id to the ORDER BY. - $order = [ - 'GROUP BY' => 'rc_timestamp, rc_id', - 'ORDER BY' => 'rc_timestamp DESC, rc_id DESC' - ]; - } else { - $order = [ 'ORDER BY' => 'rc_timestamp DESC' ]; - } - } else { - $order = []; - } - - if ( !$this->runMainQueryHook( $tables, $select, $conds, $query_options, $join_conds, - $opts ) - ) { - return false; - } - - if ( $ns == NS_CATEGORY && !$showlinkedto ) { - // special handling for categories - // XXX: should try to make this less kludgy - $link_tables = [ 'categorylinks' ]; - $showlinkedto = true; - } else { - // for now, always join on these tables; really should be configurable as in whatlinkshere - $link_tables = [ 'pagelinks', 'templatelinks' ]; - // imagelinks only contains links to pages in NS_FILE - if ( $ns == NS_FILE || !$showlinkedto ) { - $link_tables[] = 'imagelinks'; - } - } - - if ( $id == 0 && !$showlinkedto ) { - return false; // nonexistent pages can't link to any pages - } - - // field name prefixes for all the various tables we might want to join with - $prefix = [ - 'pagelinks' => 'pl', - 'templatelinks' => 'tl', - 'categorylinks' => 'cl', - 'imagelinks' => 'il' - ]; - - $subsql = []; // SELECT statements to combine with UNION - - foreach ( $link_tables as $link_table ) { - $pfx = $prefix[$link_table]; - - // imagelinks and categorylinks tables have no xx_namespace field, - // and have xx_to instead of xx_title - if ( $link_table == 'imagelinks' ) { - $link_ns = NS_FILE; - } elseif ( $link_table == 'categorylinks' ) { - $link_ns = NS_CATEGORY; - } else { - $link_ns = 0; - } - - if ( $showlinkedto ) { - // find changes to pages linking to this page - if ( $link_ns ) { - if ( $ns != $link_ns ) { - continue; - } // should never happen, but check anyway - $subconds = [ "{$pfx}_to" => $dbkey ]; - } else { - $subconds = [ "{$pfx}_namespace" => $ns, "{$pfx}_title" => $dbkey ]; - } - $subjoin = "rc_cur_id = {$pfx}_from"; - } else { - // find changes to pages linked from this page - $subconds = [ "{$pfx}_from" => $id ]; - if ( $link_table == 'imagelinks' || $link_table == 'categorylinks' ) { - $subconds["rc_namespace"] = $link_ns; - $subjoin = "rc_title = {$pfx}_to"; - } else { - $subjoin = [ "rc_namespace = {$pfx}_namespace", "rc_title = {$pfx}_title" ]; - } - } - - $query = $dbr->selectSQLText( - array_merge( $tables, [ $link_table ] ), - $select, - $conds + $subconds, - __METHOD__, - $order + $query_options, - $join_conds + [ $link_table => [ 'JOIN', $subjoin ] ] - ); - - if ( $dbr->unionSupportsOrderAndLimit() ) { - $query = $dbr->limitResult( $query, $limit ); - } - - $subsql[] = $query; - } - - if ( count( $subsql ) == 0 ) { - return false; // should never happen - } - if ( count( $subsql ) == 1 && $dbr->unionSupportsOrderAndLimit() ) { - $sql = $subsql[0]; - } else { - // need to resort and relimit after union - $sql = $dbr->unionQueries( $subsql, $dbr::UNION_DISTINCT ) . - ' ORDER BY rc_timestamp DESC'; - $sql = $dbr->limitResult( $sql, $limit, false ); - } - - $res = $dbr->query( $sql, __METHOD__ ); - - if ( $res->numRows() == 0 ) { - $this->mResultEmpty = true; - } - - return $res; - } - - function setTopText( FormOptions $opts ) { - $target = $this->getTargetTitle(); - if ( $target ) { - $this->getOutput()->addBacklinkSubtitle( $target ); - $this->getSkin()->setRelevantTitle( $target ); - } - } - - /** - * Get options to be displayed in a form - * - * @param FormOptions $opts - * @return array - */ - function getExtraOptions( $opts ) { - $extraOpts = parent::getExtraOptions( $opts ); - - $opts->consumeValues( [ 'showlinkedto', 'target' ] ); - - $extraOpts['target'] = [ $this->msg( 'recentchangeslinked-page' )->escaped(), - Xml::input( 'target', 40, str_replace( '_', ' ', $opts['target'] ) ) . - Xml::check( 'showlinkedto', $opts['showlinkedto'], [ 'id' => 'showlinkedto' ] ) . ' ' . - Xml::label( $this->msg( 'recentchangeslinked-to' )->text(), 'showlinkedto' ) ]; - - $this->addHelpLink( 'Help:Related changes' ); - return $extraOpts; - } - - /** - * @return Title - */ - function getTargetTitle() { - if ( $this->rclTargetTitle === null ) { - $opts = $this->getOptions(); - if ( isset( $opts['target'] ) && $opts['target'] !== '' ) { - $this->rclTargetTitle = Title::newFromText( $opts['target'] ); - } else { - $this->rclTargetTitle = false; - } - } - - return $this->rclTargetTitle; - } - - /** - * Return an array of subpages beginning with $search that this special page will accept. - * - * @param string $search Prefix to search for - * @param int $limit Maximum number of results to return (usually 10) - * @param int $offset Number of results to skip (usually 0) - * @return string[] Matching subpages - */ - public function prefixSearchSubpages( $search, $limit, $offset ) { - return $this->prefixSearchString( $search, $limit, $offset ); - } - - protected function outputNoResults() { - $targetTitle = $this->getTargetTitle(); - if ( $targetTitle === false ) { - $this->getOutput()->addHTML( - Html::rawElement( - 'div', - [ 'class' => 'mw-changeslist-empty mw-changeslist-notargetpage' ], - $this->msg( 'recentchanges-notargetpage' )->parse() - ) - ); - } elseif ( !$targetTitle || $targetTitle->isExternal() ) { - $this->getOutput()->addHTML( - Html::rawElement( - 'div', - [ 'class' => 'mw-changeslist-empty mw-changeslist-invalidtargetpage' ], - $this->msg( 'allpagesbadtitle' )->parse() - ) - ); - } else { - parent::outputNoResults(); - } - } -} diff --git a/includes/specials/SpecialRevisionDelete.php b/includes/specials/SpecialRevisionDelete.php new file mode 100644 index 0000000000..f0bac45ba3 --- /dev/null +++ b/includes/specials/SpecialRevisionDelete.php @@ -0,0 +1,685 @@ + [ + 'check-label' => 'revdelete-hide-text', + 'success' => 'revdelete-success', + 'failure' => 'revdelete-failure', + 'text' => 'revdelete-text-text', + 'selected' => 'revdelete-selected-text', + ], + 'archive' => [ + 'check-label' => 'revdelete-hide-text', + 'success' => 'revdelete-success', + 'failure' => 'revdelete-failure', + 'text' => 'revdelete-text-text', + 'selected' => 'revdelete-selected-text', + ], + 'oldimage' => [ + 'check-label' => 'revdelete-hide-image', + 'success' => 'revdelete-success', + 'failure' => 'revdelete-failure', + 'text' => 'revdelete-text-file', + 'selected' => 'revdelete-selected-file', + ], + 'filearchive' => [ + 'check-label' => 'revdelete-hide-image', + 'success' => 'revdelete-success', + 'failure' => 'revdelete-failure', + 'text' => 'revdelete-text-file', + 'selected' => 'revdelete-selected-file', + ], + 'logging' => [ + 'check-label' => 'revdelete-hide-name', + 'success' => 'logdelete-success', + 'failure' => 'logdelete-failure', + 'text' => 'logdelete-text', + 'selected' => 'logdelete-selected', + ], + ]; + + public function __construct() { + parent::__construct( 'Revisiondelete', 'deleterevision' ); + } + + public function doesWrites() { + return true; + } + + public function execute( $par ) { + $this->useTransactionalTimeLimit(); + + $this->checkPermissions(); + $this->checkReadOnly(); + + $output = $this->getOutput(); + $user = $this->getUser(); + + // Check blocks + if ( $user->isBlocked() ) { + throw new UserBlockedError( $user->getBlock() ); + } + + $this->setHeaders(); + $this->outputHeader(); + $request = $this->getRequest(); + $this->submitClicked = $request->wasPosted() && $request->getBool( 'wpSubmit' ); + # Handle our many different possible input types. + $ids = $request->getVal( 'ids' ); + if ( !is_null( $ids ) ) { + # Allow CSV, for backwards compatibility, or a single ID for show/hide links + $this->ids = explode( ',', $ids ); + } else { + # Array input + $this->ids = array_keys( $request->getArray( 'ids', [] ) ); + } + // $this->ids = array_map( 'intval', $this->ids ); + $this->ids = array_unique( array_filter( $this->ids ) ); + + $this->typeName = $request->getVal( 'type' ); + $this->targetObj = Title::newFromText( $request->getText( 'target' ) ); + + # For reviewing deleted files... + $this->archiveName = $request->getVal( 'file' ); + $this->token = $request->getVal( 'token' ); + if ( $this->archiveName && $this->targetObj ) { + $this->tryShowFile( $this->archiveName ); + + return; + } + + $this->typeName = RevisionDeleter::getCanonicalTypeName( $this->typeName ); + + # No targets? + if ( !$this->typeName || count( $this->ids ) == 0 ) { + throw new ErrorPageError( 'revdelete-nooldid-title', 'revdelete-nooldid-text' ); + } + + # Allow the list type to adjust the passed target + $this->targetObj = RevisionDeleter::suggestTarget( + $this->typeName, + $this->targetObj, + $this->ids + ); + + # We need a target page! + if ( $this->targetObj === null ) { + $output->addWikiMsg( 'undelete-header' ); + + return; + } + + $this->typeLabels = self::$UILabels[$this->typeName]; + $list = $this->getList(); + $list->reset(); + $this->mIsAllowed = $user->isAllowed( RevisionDeleter::getRestriction( $this->typeName ) ); + $canViewSuppressedOnly = $this->getUser()->isAllowed( 'viewsuppressed' ) && + !$this->getUser()->isAllowed( 'suppressrevision' ); + $pageIsSuppressed = $list->areAnySuppressed(); + $this->mIsAllowed = $this->mIsAllowed && !( $canViewSuppressedOnly && $pageIsSuppressed ); + + $this->otherReason = $request->getVal( 'wpReason' ); + # Give a link to the logs/hist for this page + $this->showConvenienceLinks(); + + # Initialise checkboxes + $this->checks = [ + # Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name + [ $this->typeLabels['check-label'], 'wpHidePrimary', + RevisionDeleter::getRevdelConstant( $this->typeName ) + ], + [ 'revdelete-hide-comment', 'wpHideComment', Revision::DELETED_COMMENT ], + [ 'revdelete-hide-user', 'wpHideUser', Revision::DELETED_USER ] + ]; + if ( $user->isAllowed( 'suppressrevision' ) ) { + $this->checks[] = [ 'revdelete-hide-restricted', + 'wpHideRestricted', Revision::DELETED_RESTRICTED ]; + } + + # Either submit or create our form + if ( $this->mIsAllowed && $this->submitClicked ) { + $this->submit(); + } else { + $this->showForm(); + } + + if ( $user->isAllowed( 'deletedhistory' ) ) { + $qc = $this->getLogQueryCond(); + # Show relevant lines from the deletion log + $deleteLogPage = new LogPage( 'delete' ); + $output->addHTML( "

" . $deleteLogPage->getName()->escaped() . "

\n" ); + LogEventsList::showLogExtract( + $output, + 'delete', + $this->targetObj, + '', /* user */ + [ 'lim' => 25, 'conds' => $qc, 'useMaster' => $this->wasSaved ] + ); + } + # Show relevant lines from the suppression log + if ( $user->isAllowed( 'suppressionlog' ) ) { + $suppressLogPage = new LogPage( 'suppress' ); + $output->addHTML( "

" . $suppressLogPage->getName()->escaped() . "

\n" ); + LogEventsList::showLogExtract( + $output, + 'suppress', + $this->targetObj, + '', + [ 'lim' => 25, 'conds' => $qc, 'useMaster' => $this->wasSaved ] + ); + } + } + + /** + * Show some useful links in the subtitle + */ + protected function showConvenienceLinks() { + $linkRenderer = $this->getLinkRenderer(); + # Give a link to the logs/hist for this page + if ( $this->targetObj ) { + // Also set header tabs to be for the target. + $this->getSkin()->setRelevantTitle( $this->targetObj ); + + $links = []; + $links[] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Log' ), + $this->msg( 'viewpagelogs' )->text(), + [], + [ 'page' => $this->targetObj->getPrefixedText() ] + ); + if ( !$this->targetObj->isSpecialPage() ) { + # Give a link to the page history + $links[] = $linkRenderer->makeKnownLink( + $this->targetObj, + $this->msg( 'pagehist' )->text(), + [], + [ 'action' => 'history' ] + ); + # Link to deleted edits + if ( $this->getUser()->isAllowed( 'undelete' ) ) { + $undelete = SpecialPage::getTitleFor( 'Undelete' ); + $links[] = $linkRenderer->makeKnownLink( + $undelete, + $this->msg( 'deletedhist' )->text(), + [], + [ 'target' => $this->targetObj->getPrefixedDBkey() ] + ); + } + } + # Logs themselves don't have histories or archived revisions + $this->getOutput()->addSubtitle( $this->getLanguage()->pipeList( $links ) ); + } + } + + /** + * Get the condition used for fetching log snippets + * @return array + */ + protected function getLogQueryCond() { + $conds = []; + // Revision delete logs for these item + $conds['log_type'] = [ 'delete', 'suppress' ]; + $conds['log_action'] = $this->getList()->getLogAction(); + $conds['ls_field'] = RevisionDeleter::getRelationType( $this->typeName ); + $conds['ls_value'] = $this->ids; + + return $conds; + } + + /** + * Show a deleted file version requested by the visitor. + * @todo Mostly copied from Special:Undelete. Refactor. + * @param string $archiveName + * @throws MWException + * @throws PermissionsError + */ + protected function tryShowFile( $archiveName ) { + $repo = RepoGroup::singleton()->getLocalRepo(); + $oimage = $repo->newFromArchiveName( $this->targetObj, $archiveName ); + $oimage->load(); + // Check if user is allowed to see this file + if ( !$oimage->exists() ) { + $this->getOutput()->addWikiMsg( 'revdelete-no-file' ); + + return; + } + $user = $this->getUser(); + if ( !$oimage->userCan( File::DELETED_FILE, $user ) ) { + if ( $oimage->isDeleted( File::DELETED_RESTRICTED ) ) { + throw new PermissionsError( 'suppressrevision' ); + } else { + throw new PermissionsError( 'deletedtext' ); + } + } + if ( !$user->matchEditToken( $this->token, $archiveName ) ) { + $lang = $this->getLanguage(); + $this->getOutput()->addWikiMsg( 'revdelete-show-file-confirm', + $this->targetObj->getText(), + $lang->userDate( $oimage->getTimestamp(), $user ), + $lang->userTime( $oimage->getTimestamp(), $user ) ); + $this->getOutput()->addHTML( + Xml::openElement( 'form', [ + 'method' => 'POST', + 'action' => $this->getPageTitle()->getLocalURL( [ + 'target' => $this->targetObj->getPrefixedDBkey(), + 'file' => $archiveName, + 'token' => $user->getEditToken( $archiveName ), + ] ) + ] + ) . + Xml::submitButton( $this->msg( 'revdelete-show-file-submit' )->text() ) . + '' + ); + + return; + } + $this->getOutput()->disable(); + # We mustn't allow the output to be CDN cached, otherwise + # if an admin previews a deleted image, and it's cached, then + # a user without appropriate permissions can toddle off and + # nab the image, and CDN will serve it + $this->getRequest()->response()->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' ); + $this->getRequest()->response()->header( + 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' + ); + $this->getRequest()->response()->header( 'Pragma: no-cache' ); + + $key = $oimage->getStorageKey(); + $path = $repo->getZonePath( 'deleted' ) . '/' . $repo->getDeletedHashPath( $key ) . $key; + $repo->streamFile( $path ); + } + + /** + * Get the list object for this request + * @return RevDelList + */ + protected function getList() { + if ( is_null( $this->revDelList ) ) { + $this->revDelList = RevisionDeleter::createList( + $this->typeName, $this->getContext(), $this->targetObj, $this->ids + ); + } + + return $this->revDelList; + } + + /** + * Show a list of items that we will operate on, and show a form with checkboxes + * which will allow the user to choose new visibility settings. + */ + protected function showForm() { + $userAllowed = true; + + // Messages: revdelete-selected-text, revdelete-selected-file, logdelete-selected + $out = $this->getOutput(); + $out->wrapWikiMsg( "$1", [ $this->typeLabels['selected'], + $this->getLanguage()->formatNum( count( $this->ids ) ), $this->targetObj->getPrefixedText() ] ); + + $this->addHelpLink( 'Help:RevisionDelete' ); + $out->addHTML( "" ); + // Explanation text + $this->addUsageText(); + + // Normal sysops can always see what they did, but can't always change it + if ( !$userAllowed ) { + return; + } + + // Show form if the user can submit + if ( $this->mIsAllowed ) { + $out->addModules( [ 'mediawiki.special.revisionDelete' ] ); + $out->addModuleStyles( [ 'mediawiki.special', + 'mediawiki.interface.helpers.styles' ] ); + + $form = Xml::openElement( 'form', [ 'method' => 'post', + 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ), + 'id' => 'mw-revdel-form-revisions' ] ) . + Xml::fieldset( $this->msg( 'revdelete-legend' )->text() ) . + $this->buildCheckBoxes() . + Xml::openElement( 'table' ) . + "\n" . + '' . + Xml::label( $this->msg( 'revdelete-log' )->text(), 'wpRevDeleteReasonList' ) . + '' . + '' . + Xml::listDropDown( 'wpRevDeleteReasonList', + $this->msg( 'revdelete-reason-dropdown' )->inContentLanguage()->text(), + $this->msg( 'revdelete-reasonotherlist' )->inContentLanguage()->text(), + $this->getRequest()->getText( 'wpRevDeleteReasonList', 'other' ), 'wpReasonDropDown' + ) . + '' . + "\n" . + '' . + Xml::label( $this->msg( 'revdelete-otherreason' )->text(), 'wpReason' ) . + '' . + '' . + Xml::input( 'wpReason', 60, $this->otherReason, [ + 'id' => 'wpReason', + // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP + // (e.g. emojis) count for two each. This limit is overridden in JS to instead count + // Unicode codepoints. + // "- 155" is to leave room for the 'wpRevDeleteReasonList' value. + 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT - 155, + ] ) . + '' . + "\n" . + '' . + '' . + Xml::submitButton( $this->msg( 'revdelete-submit', $numRevisions )->text(), + [ 'name' => 'wpSubmit' ] ) . + '' . + "\n" . + Xml::closeElement( 'table' ) . + Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) . + Html::hidden( 'target', $this->targetObj->getPrefixedText() ) . + Html::hidden( 'type', $this->typeName ) . + Html::hidden( 'ids', implode( ',', $this->ids ) ) . + Xml::closeElement( 'fieldset' ) . "\n" . + Xml::closeElement( 'form' ) . "\n"; + // Show link to edit the dropdown reasons + if ( $this->getUser()->isAllowed( 'editinterface' ) ) { + $link = $this->getLinkRenderer()->makeKnownLink( + $this->msg( 'revdelete-reason-dropdown' )->inContentLanguage()->getTitle(), + $this->msg( 'revdelete-edit-reasonlist' )->text(), + [], + [ 'action' => 'edit' ] + ); + $form .= Xml::tags( 'p', [ 'class' => 'mw-revdel-editreasons' ], $link ) . "\n"; + } + } else { + $form = ''; + } + $out->addHTML( $form ); + } + + /** + * Show some introductory text + * @todo FIXME: Wikimedia-specific policy text + */ + protected function addUsageText() { + // Messages: revdelete-text-text, revdelete-text-file, logdelete-text + $this->getOutput()->wrapWikiMsg( + "$1\n$2", $this->typeLabels['text'], + 'revdelete-text-others' + ); + + if ( $this->getUser()->isAllowed( 'suppressrevision' ) ) { + $this->getOutput()->addWikiMsg( 'revdelete-suppress-text' ); + } + + if ( $this->mIsAllowed ) { + $this->getOutput()->addWikiMsg( 'revdelete-confirm' ); + } + } + + /** + * @return string HTML + */ + protected function buildCheckBoxes() { + $html = ''; + // If there is just one item, use checkboxes + $list = $this->getList(); + if ( $list->length() == 1 ) { + $list->reset(); + $bitfield = $list->current()->getBits(); // existing field + + if ( $this->submitClicked ) { + $bitfield = RevisionDeleter::extractBitfield( $this->extractBitParams(), $bitfield ); + } + + foreach ( $this->checks as $item ) { + // Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name, + // revdelete-hide-comment, revdelete-hide-user, revdelete-hide-restricted + list( $message, $name, $field ) = $item; + $innerHTML = Xml::checkLabel( + $this->msg( $message )->text(), + $name, + $name, + $bitfield & $field + ); + + if ( $field == Revision::DELETED_RESTRICTED ) { + $innerHTML = "$innerHTML"; + } + + $line = Xml::tags( 'td', [ 'class' => 'mw-input' ], $innerHTML ); + $html .= "$line\n"; + } + } else { + // Otherwise, use tri-state radios + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= "\n"; + foreach ( $this->checks as $item ) { + // Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name, + // revdelete-hide-comment, revdelete-hide-user, revdelete-hide-restricted + list( $message, $name, $field ) = $item; + // If there are several items, use third state by default... + if ( $this->submitClicked ) { + $selected = $this->getRequest()->getInt( $name, 0 /* unchecked */ ); + } else { + $selected = -1; // use existing field + } + $line = ''; + $line .= ''; + $line .= ''; + $label = $this->msg( $message )->escaped(); + if ( $field == Revision::DELETED_RESTRICTED ) { + $label = "$label"; + } + $line .= ""; + $html .= "$line\n"; + } + } + + $html .= '
' + . $this->msg( 'revdelete-radio-same' )->escaped() . '' + . $this->msg( 'revdelete-radio-unset' )->escaped() . '' + . $this->msg( 'revdelete-radio-set' )->escaped() . '
' . Xml::radio( $name, -1, $selected == -1 ) . '' . Xml::radio( $name, 0, $selected == 0 ) . '' . Xml::radio( $name, 1, $selected == 1 ) . '$label
'; + + return $html; + } + + /** + * UI entry point for form submission. + * @throws PermissionsError + * @return bool + */ + protected function submit() { + # Check edit token on submission + $token = $this->getRequest()->getVal( 'wpEditToken' ); + if ( $this->submitClicked && !$this->getUser()->matchEditToken( $token ) ) { + $this->getOutput()->addWikiMsg( 'sessionfailure' ); + + return false; + } + $bitParams = $this->extractBitParams(); + // from dropdown + $listReason = $this->getRequest()->getText( 'wpRevDeleteReasonList', 'other' ); + $comment = $listReason; + if ( $comment === 'other' ) { + $comment = $this->otherReason; + } elseif ( $this->otherReason !== '' ) { + // Entry from drop down menu + additional comment + $comment .= $this->msg( 'colon-separator' )->inContentLanguage()->text() + . $this->otherReason; + } + # Can the user set this field? + if ( $bitParams[Revision::DELETED_RESTRICTED] == 1 + && !$this->getUser()->isAllowed( 'suppressrevision' ) + ) { + throw new PermissionsError( 'suppressrevision' ); + } + # If the save went through, go to success message... + $status = $this->save( $bitParams, $comment ); + if ( $status->isGood() ) { + $this->success(); + + return true; + } else { + # ...otherwise, bounce back to form... + $this->failure( $status ); + } + + return false; + } + + /** + * Report that the submit operation succeeded + */ + protected function success() { + // Messages: revdelete-success, logdelete-success + $this->getOutput()->setPageTitle( $this->msg( 'actioncomplete' ) ); + $this->getOutput()->wrapWikiMsg( + "
\n$1\n
", + $this->typeLabels['success'] + ); + $this->wasSaved = true; + $this->revDelList->reloadFromMaster(); + $this->showForm(); + } + + /** + * Report that the submit operation failed + * @param Status $status + */ + protected function failure( $status ) { + // Messages: revdelete-failure, logdelete-failure + $this->getOutput()->setPageTitle( $this->msg( 'actionfailed' ) ); + $this->getOutput()->wrapWikiTextAsInterface( + 'errorbox', + $status->getWikiText( $this->typeLabels['failure'] ) + ); + $this->showForm(); + } + + /** + * Put together an array that contains -1, 0, or the *_deleted const for each bit + * + * @return array + */ + protected function extractBitParams() { + $bitfield = []; + foreach ( $this->checks as $item ) { + list( /* message */, $name, $field ) = $item; + $val = $this->getRequest()->getInt( $name, 0 /* unchecked */ ); + if ( $val < -1 || $val > 1 ) { + $val = -1; // -1 for existing value + } + $bitfield[$field] = $val; + } + if ( !isset( $bitfield[Revision::DELETED_RESTRICTED] ) ) { + $bitfield[Revision::DELETED_RESTRICTED] = 0; + } + + return $bitfield; + } + + /** + * Do the write operations. Simple wrapper for RevDel*List::setVisibility(). + * @param array $bitPars ExtractBitParams() bitfield array + * @param string $reason + * @return Status + */ + protected function save( array $bitPars, $reason ) { + return $this->getList()->setVisibility( + [ 'value' => $bitPars, 'comment' => $reason ] + ); + } + + protected function getGroupName() { + return 'pagetools'; + } +} diff --git a/includes/specials/SpecialRevisiondelete.php b/includes/specials/SpecialRevisiondelete.php deleted file mode 100644 index f0bac45ba3..0000000000 --- a/includes/specials/SpecialRevisiondelete.php +++ /dev/null @@ -1,685 +0,0 @@ - [ - 'check-label' => 'revdelete-hide-text', - 'success' => 'revdelete-success', - 'failure' => 'revdelete-failure', - 'text' => 'revdelete-text-text', - 'selected' => 'revdelete-selected-text', - ], - 'archive' => [ - 'check-label' => 'revdelete-hide-text', - 'success' => 'revdelete-success', - 'failure' => 'revdelete-failure', - 'text' => 'revdelete-text-text', - 'selected' => 'revdelete-selected-text', - ], - 'oldimage' => [ - 'check-label' => 'revdelete-hide-image', - 'success' => 'revdelete-success', - 'failure' => 'revdelete-failure', - 'text' => 'revdelete-text-file', - 'selected' => 'revdelete-selected-file', - ], - 'filearchive' => [ - 'check-label' => 'revdelete-hide-image', - 'success' => 'revdelete-success', - 'failure' => 'revdelete-failure', - 'text' => 'revdelete-text-file', - 'selected' => 'revdelete-selected-file', - ], - 'logging' => [ - 'check-label' => 'revdelete-hide-name', - 'success' => 'logdelete-success', - 'failure' => 'logdelete-failure', - 'text' => 'logdelete-text', - 'selected' => 'logdelete-selected', - ], - ]; - - public function __construct() { - parent::__construct( 'Revisiondelete', 'deleterevision' ); - } - - public function doesWrites() { - return true; - } - - public function execute( $par ) { - $this->useTransactionalTimeLimit(); - - $this->checkPermissions(); - $this->checkReadOnly(); - - $output = $this->getOutput(); - $user = $this->getUser(); - - // Check blocks - if ( $user->isBlocked() ) { - throw new UserBlockedError( $user->getBlock() ); - } - - $this->setHeaders(); - $this->outputHeader(); - $request = $this->getRequest(); - $this->submitClicked = $request->wasPosted() && $request->getBool( 'wpSubmit' ); - # Handle our many different possible input types. - $ids = $request->getVal( 'ids' ); - if ( !is_null( $ids ) ) { - # Allow CSV, for backwards compatibility, or a single ID for show/hide links - $this->ids = explode( ',', $ids ); - } else { - # Array input - $this->ids = array_keys( $request->getArray( 'ids', [] ) ); - } - // $this->ids = array_map( 'intval', $this->ids ); - $this->ids = array_unique( array_filter( $this->ids ) ); - - $this->typeName = $request->getVal( 'type' ); - $this->targetObj = Title::newFromText( $request->getText( 'target' ) ); - - # For reviewing deleted files... - $this->archiveName = $request->getVal( 'file' ); - $this->token = $request->getVal( 'token' ); - if ( $this->archiveName && $this->targetObj ) { - $this->tryShowFile( $this->archiveName ); - - return; - } - - $this->typeName = RevisionDeleter::getCanonicalTypeName( $this->typeName ); - - # No targets? - if ( !$this->typeName || count( $this->ids ) == 0 ) { - throw new ErrorPageError( 'revdelete-nooldid-title', 'revdelete-nooldid-text' ); - } - - # Allow the list type to adjust the passed target - $this->targetObj = RevisionDeleter::suggestTarget( - $this->typeName, - $this->targetObj, - $this->ids - ); - - # We need a target page! - if ( $this->targetObj === null ) { - $output->addWikiMsg( 'undelete-header' ); - - return; - } - - $this->typeLabels = self::$UILabels[$this->typeName]; - $list = $this->getList(); - $list->reset(); - $this->mIsAllowed = $user->isAllowed( RevisionDeleter::getRestriction( $this->typeName ) ); - $canViewSuppressedOnly = $this->getUser()->isAllowed( 'viewsuppressed' ) && - !$this->getUser()->isAllowed( 'suppressrevision' ); - $pageIsSuppressed = $list->areAnySuppressed(); - $this->mIsAllowed = $this->mIsAllowed && !( $canViewSuppressedOnly && $pageIsSuppressed ); - - $this->otherReason = $request->getVal( 'wpReason' ); - # Give a link to the logs/hist for this page - $this->showConvenienceLinks(); - - # Initialise checkboxes - $this->checks = [ - # Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name - [ $this->typeLabels['check-label'], 'wpHidePrimary', - RevisionDeleter::getRevdelConstant( $this->typeName ) - ], - [ 'revdelete-hide-comment', 'wpHideComment', Revision::DELETED_COMMENT ], - [ 'revdelete-hide-user', 'wpHideUser', Revision::DELETED_USER ] - ]; - if ( $user->isAllowed( 'suppressrevision' ) ) { - $this->checks[] = [ 'revdelete-hide-restricted', - 'wpHideRestricted', Revision::DELETED_RESTRICTED ]; - } - - # Either submit or create our form - if ( $this->mIsAllowed && $this->submitClicked ) { - $this->submit(); - } else { - $this->showForm(); - } - - if ( $user->isAllowed( 'deletedhistory' ) ) { - $qc = $this->getLogQueryCond(); - # Show relevant lines from the deletion log - $deleteLogPage = new LogPage( 'delete' ); - $output->addHTML( "

" . $deleteLogPage->getName()->escaped() . "

\n" ); - LogEventsList::showLogExtract( - $output, - 'delete', - $this->targetObj, - '', /* user */ - [ 'lim' => 25, 'conds' => $qc, 'useMaster' => $this->wasSaved ] - ); - } - # Show relevant lines from the suppression log - if ( $user->isAllowed( 'suppressionlog' ) ) { - $suppressLogPage = new LogPage( 'suppress' ); - $output->addHTML( "

" . $suppressLogPage->getName()->escaped() . "

\n" ); - LogEventsList::showLogExtract( - $output, - 'suppress', - $this->targetObj, - '', - [ 'lim' => 25, 'conds' => $qc, 'useMaster' => $this->wasSaved ] - ); - } - } - - /** - * Show some useful links in the subtitle - */ - protected function showConvenienceLinks() { - $linkRenderer = $this->getLinkRenderer(); - # Give a link to the logs/hist for this page - if ( $this->targetObj ) { - // Also set header tabs to be for the target. - $this->getSkin()->setRelevantTitle( $this->targetObj ); - - $links = []; - $links[] = $linkRenderer->makeKnownLink( - SpecialPage::getTitleFor( 'Log' ), - $this->msg( 'viewpagelogs' )->text(), - [], - [ 'page' => $this->targetObj->getPrefixedText() ] - ); - if ( !$this->targetObj->isSpecialPage() ) { - # Give a link to the page history - $links[] = $linkRenderer->makeKnownLink( - $this->targetObj, - $this->msg( 'pagehist' )->text(), - [], - [ 'action' => 'history' ] - ); - # Link to deleted edits - if ( $this->getUser()->isAllowed( 'undelete' ) ) { - $undelete = SpecialPage::getTitleFor( 'Undelete' ); - $links[] = $linkRenderer->makeKnownLink( - $undelete, - $this->msg( 'deletedhist' )->text(), - [], - [ 'target' => $this->targetObj->getPrefixedDBkey() ] - ); - } - } - # Logs themselves don't have histories or archived revisions - $this->getOutput()->addSubtitle( $this->getLanguage()->pipeList( $links ) ); - } - } - - /** - * Get the condition used for fetching log snippets - * @return array - */ - protected function getLogQueryCond() { - $conds = []; - // Revision delete logs for these item - $conds['log_type'] = [ 'delete', 'suppress' ]; - $conds['log_action'] = $this->getList()->getLogAction(); - $conds['ls_field'] = RevisionDeleter::getRelationType( $this->typeName ); - $conds['ls_value'] = $this->ids; - - return $conds; - } - - /** - * Show a deleted file version requested by the visitor. - * @todo Mostly copied from Special:Undelete. Refactor. - * @param string $archiveName - * @throws MWException - * @throws PermissionsError - */ - protected function tryShowFile( $archiveName ) { - $repo = RepoGroup::singleton()->getLocalRepo(); - $oimage = $repo->newFromArchiveName( $this->targetObj, $archiveName ); - $oimage->load(); - // Check if user is allowed to see this file - if ( !$oimage->exists() ) { - $this->getOutput()->addWikiMsg( 'revdelete-no-file' ); - - return; - } - $user = $this->getUser(); - if ( !$oimage->userCan( File::DELETED_FILE, $user ) ) { - if ( $oimage->isDeleted( File::DELETED_RESTRICTED ) ) { - throw new PermissionsError( 'suppressrevision' ); - } else { - throw new PermissionsError( 'deletedtext' ); - } - } - if ( !$user->matchEditToken( $this->token, $archiveName ) ) { - $lang = $this->getLanguage(); - $this->getOutput()->addWikiMsg( 'revdelete-show-file-confirm', - $this->targetObj->getText(), - $lang->userDate( $oimage->getTimestamp(), $user ), - $lang->userTime( $oimage->getTimestamp(), $user ) ); - $this->getOutput()->addHTML( - Xml::openElement( 'form', [ - 'method' => 'POST', - 'action' => $this->getPageTitle()->getLocalURL( [ - 'target' => $this->targetObj->getPrefixedDBkey(), - 'file' => $archiveName, - 'token' => $user->getEditToken( $archiveName ), - ] ) - ] - ) . - Xml::submitButton( $this->msg( 'revdelete-show-file-submit' )->text() ) . - '' - ); - - return; - } - $this->getOutput()->disable(); - # We mustn't allow the output to be CDN cached, otherwise - # if an admin previews a deleted image, and it's cached, then - # a user without appropriate permissions can toddle off and - # nab the image, and CDN will serve it - $this->getRequest()->response()->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' ); - $this->getRequest()->response()->header( - 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' - ); - $this->getRequest()->response()->header( 'Pragma: no-cache' ); - - $key = $oimage->getStorageKey(); - $path = $repo->getZonePath( 'deleted' ) . '/' . $repo->getDeletedHashPath( $key ) . $key; - $repo->streamFile( $path ); - } - - /** - * Get the list object for this request - * @return RevDelList - */ - protected function getList() { - if ( is_null( $this->revDelList ) ) { - $this->revDelList = RevisionDeleter::createList( - $this->typeName, $this->getContext(), $this->targetObj, $this->ids - ); - } - - return $this->revDelList; - } - - /** - * Show a list of items that we will operate on, and show a form with checkboxes - * which will allow the user to choose new visibility settings. - */ - protected function showForm() { - $userAllowed = true; - - // Messages: revdelete-selected-text, revdelete-selected-file, logdelete-selected - $out = $this->getOutput(); - $out->wrapWikiMsg( "$1", [ $this->typeLabels['selected'], - $this->getLanguage()->formatNum( count( $this->ids ) ), $this->targetObj->getPrefixedText() ] ); - - $this->addHelpLink( 'Help:RevisionDelete' ); - $out->addHTML( "" ); - // Explanation text - $this->addUsageText(); - - // Normal sysops can always see what they did, but can't always change it - if ( !$userAllowed ) { - return; - } - - // Show form if the user can submit - if ( $this->mIsAllowed ) { - $out->addModules( [ 'mediawiki.special.revisionDelete' ] ); - $out->addModuleStyles( [ 'mediawiki.special', - 'mediawiki.interface.helpers.styles' ] ); - - $form = Xml::openElement( 'form', [ 'method' => 'post', - 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ), - 'id' => 'mw-revdel-form-revisions' ] ) . - Xml::fieldset( $this->msg( 'revdelete-legend' )->text() ) . - $this->buildCheckBoxes() . - Xml::openElement( 'table' ) . - "\n" . - '' . - Xml::label( $this->msg( 'revdelete-log' )->text(), 'wpRevDeleteReasonList' ) . - '' . - '' . - Xml::listDropDown( 'wpRevDeleteReasonList', - $this->msg( 'revdelete-reason-dropdown' )->inContentLanguage()->text(), - $this->msg( 'revdelete-reasonotherlist' )->inContentLanguage()->text(), - $this->getRequest()->getText( 'wpRevDeleteReasonList', 'other' ), 'wpReasonDropDown' - ) . - '' . - "\n" . - '' . - Xml::label( $this->msg( 'revdelete-otherreason' )->text(), 'wpReason' ) . - '' . - '' . - Xml::input( 'wpReason', 60, $this->otherReason, [ - 'id' => 'wpReason', - // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP - // (e.g. emojis) count for two each. This limit is overridden in JS to instead count - // Unicode codepoints. - // "- 155" is to leave room for the 'wpRevDeleteReasonList' value. - 'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT - 155, - ] ) . - '' . - "\n" . - '' . - '' . - Xml::submitButton( $this->msg( 'revdelete-submit', $numRevisions )->text(), - [ 'name' => 'wpSubmit' ] ) . - '' . - "\n" . - Xml::closeElement( 'table' ) . - Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) . - Html::hidden( 'target', $this->targetObj->getPrefixedText() ) . - Html::hidden( 'type', $this->typeName ) . - Html::hidden( 'ids', implode( ',', $this->ids ) ) . - Xml::closeElement( 'fieldset' ) . "\n" . - Xml::closeElement( 'form' ) . "\n"; - // Show link to edit the dropdown reasons - if ( $this->getUser()->isAllowed( 'editinterface' ) ) { - $link = $this->getLinkRenderer()->makeKnownLink( - $this->msg( 'revdelete-reason-dropdown' )->inContentLanguage()->getTitle(), - $this->msg( 'revdelete-edit-reasonlist' )->text(), - [], - [ 'action' => 'edit' ] - ); - $form .= Xml::tags( 'p', [ 'class' => 'mw-revdel-editreasons' ], $link ) . "\n"; - } - } else { - $form = ''; - } - $out->addHTML( $form ); - } - - /** - * Show some introductory text - * @todo FIXME: Wikimedia-specific policy text - */ - protected function addUsageText() { - // Messages: revdelete-text-text, revdelete-text-file, logdelete-text - $this->getOutput()->wrapWikiMsg( - "$1\n$2", $this->typeLabels['text'], - 'revdelete-text-others' - ); - - if ( $this->getUser()->isAllowed( 'suppressrevision' ) ) { - $this->getOutput()->addWikiMsg( 'revdelete-suppress-text' ); - } - - if ( $this->mIsAllowed ) { - $this->getOutput()->addWikiMsg( 'revdelete-confirm' ); - } - } - - /** - * @return string HTML - */ - protected function buildCheckBoxes() { - $html = ''; - // If there is just one item, use checkboxes - $list = $this->getList(); - if ( $list->length() == 1 ) { - $list->reset(); - $bitfield = $list->current()->getBits(); // existing field - - if ( $this->submitClicked ) { - $bitfield = RevisionDeleter::extractBitfield( $this->extractBitParams(), $bitfield ); - } - - foreach ( $this->checks as $item ) { - // Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name, - // revdelete-hide-comment, revdelete-hide-user, revdelete-hide-restricted - list( $message, $name, $field ) = $item; - $innerHTML = Xml::checkLabel( - $this->msg( $message )->text(), - $name, - $name, - $bitfield & $field - ); - - if ( $field == Revision::DELETED_RESTRICTED ) { - $innerHTML = "$innerHTML"; - } - - $line = Xml::tags( 'td', [ 'class' => 'mw-input' ], $innerHTML ); - $html .= "$line\n"; - } - } else { - // Otherwise, use tri-state radios - $html .= ''; - $html .= ''; - $html .= ''; - $html .= ''; - $html .= "\n"; - foreach ( $this->checks as $item ) { - // Messages: revdelete-hide-text, revdelete-hide-image, revdelete-hide-name, - // revdelete-hide-comment, revdelete-hide-user, revdelete-hide-restricted - list( $message, $name, $field ) = $item; - // If there are several items, use third state by default... - if ( $this->submitClicked ) { - $selected = $this->getRequest()->getInt( $name, 0 /* unchecked */ ); - } else { - $selected = -1; // use existing field - } - $line = ''; - $line .= ''; - $line .= ''; - $label = $this->msg( $message )->escaped(); - if ( $field == Revision::DELETED_RESTRICTED ) { - $label = "$label"; - } - $line .= ""; - $html .= "$line\n"; - } - } - - $html .= '
' - . $this->msg( 'revdelete-radio-same' )->escaped() . '' - . $this->msg( 'revdelete-radio-unset' )->escaped() . '' - . $this->msg( 'revdelete-radio-set' )->escaped() . '
' . Xml::radio( $name, -1, $selected == -1 ) . '' . Xml::radio( $name, 0, $selected == 0 ) . '' . Xml::radio( $name, 1, $selected == 1 ) . '$label
'; - - return $html; - } - - /** - * UI entry point for form submission. - * @throws PermissionsError - * @return bool - */ - protected function submit() { - # Check edit token on submission - $token = $this->getRequest()->getVal( 'wpEditToken' ); - if ( $this->submitClicked && !$this->getUser()->matchEditToken( $token ) ) { - $this->getOutput()->addWikiMsg( 'sessionfailure' ); - - return false; - } - $bitParams = $this->extractBitParams(); - // from dropdown - $listReason = $this->getRequest()->getText( 'wpRevDeleteReasonList', 'other' ); - $comment = $listReason; - if ( $comment === 'other' ) { - $comment = $this->otherReason; - } elseif ( $this->otherReason !== '' ) { - // Entry from drop down menu + additional comment - $comment .= $this->msg( 'colon-separator' )->inContentLanguage()->text() - . $this->otherReason; - } - # Can the user set this field? - if ( $bitParams[Revision::DELETED_RESTRICTED] == 1 - && !$this->getUser()->isAllowed( 'suppressrevision' ) - ) { - throw new PermissionsError( 'suppressrevision' ); - } - # If the save went through, go to success message... - $status = $this->save( $bitParams, $comment ); - if ( $status->isGood() ) { - $this->success(); - - return true; - } else { - # ...otherwise, bounce back to form... - $this->failure( $status ); - } - - return false; - } - - /** - * Report that the submit operation succeeded - */ - protected function success() { - // Messages: revdelete-success, logdelete-success - $this->getOutput()->setPageTitle( $this->msg( 'actioncomplete' ) ); - $this->getOutput()->wrapWikiMsg( - "
\n$1\n
", - $this->typeLabels['success'] - ); - $this->wasSaved = true; - $this->revDelList->reloadFromMaster(); - $this->showForm(); - } - - /** - * Report that the submit operation failed - * @param Status $status - */ - protected function failure( $status ) { - // Messages: revdelete-failure, logdelete-failure - $this->getOutput()->setPageTitle( $this->msg( 'actionfailed' ) ); - $this->getOutput()->wrapWikiTextAsInterface( - 'errorbox', - $status->getWikiText( $this->typeLabels['failure'] ) - ); - $this->showForm(); - } - - /** - * Put together an array that contains -1, 0, or the *_deleted const for each bit - * - * @return array - */ - protected function extractBitParams() { - $bitfield = []; - foreach ( $this->checks as $item ) { - list( /* message */, $name, $field ) = $item; - $val = $this->getRequest()->getInt( $name, 0 /* unchecked */ ); - if ( $val < -1 || $val > 1 ) { - $val = -1; // -1 for existing value - } - $bitfield[$field] = $val; - } - if ( !isset( $bitfield[Revision::DELETED_RESTRICTED] ) ) { - $bitfield[Revision::DELETED_RESTRICTED] = 0; - } - - return $bitfield; - } - - /** - * Do the write operations. Simple wrapper for RevDel*List::setVisibility(). - * @param array $bitPars ExtractBitParams() bitfield array - * @param string $reason - * @return Status - */ - protected function save( array $bitPars, $reason ) { - return $this->getList()->setVisibility( - [ 'value' => $bitPars, 'comment' => $reason ] - ); - } - - protected function getGroupName() { - return 'pagetools'; - } -} diff --git a/includes/specials/SpecialWhatLinksHere.php b/includes/specials/SpecialWhatLinksHere.php new file mode 100644 index 0000000000..18c10bf706 --- /dev/null +++ b/includes/specials/SpecialWhatLinksHere.php @@ -0,0 +1,591 @@ +getOutput(); + + $this->setHeaders(); + $this->outputHeader(); + $this->addHelpLink( 'Help:What links here' ); + + $opts = new FormOptions(); + + $opts->add( 'target', '' ); + $opts->add( 'namespace', '', FormOptions::INTNULL ); + $opts->add( 'limit', $this->getConfig()->get( 'QueryPageDefaultLimit' ) ); + $opts->add( 'from', 0 ); + $opts->add( 'back', 0 ); + $opts->add( 'hideredirs', false ); + $opts->add( 'hidetrans', false ); + $opts->add( 'hidelinks', false ); + $opts->add( 'hideimages', false ); + $opts->add( 'invert', false ); + + $opts->fetchValuesFromRequest( $this->getRequest() ); + $opts->validateIntBounds( 'limit', 0, 5000 ); + + // Give precedence to subpage syntax + if ( $par !== null ) { + $opts->setValue( 'target', $par ); + } + + // Bind to member variable + $this->opts = $opts; + + $this->target = Title::newFromText( $opts->getValue( 'target' ) ); + if ( !$this->target ) { + if ( !$this->including() ) { + $out->addHTML( $this->whatlinkshereForm() ); + } + + return; + } + + $this->getSkin()->setRelevantTitle( $this->target ); + + $this->selfTitle = $this->getPageTitle( $this->target->getPrefixedDBkey() ); + + $out->setPageTitle( $this->msg( 'whatlinkshere-title', $this->target->getPrefixedText() ) ); + $out->addBacklinkSubtitle( $this->target ); + $this->showIndirectLinks( + 0, + $this->target, + $opts->getValue( 'limit' ), + $opts->getValue( 'from' ), + $opts->getValue( 'back' ) + ); + } + + /** + * @param int $level Recursion level + * @param Title $target Target title + * @param int $limit Number of entries to display + * @param int $from Display from this article ID (default: 0) + * @param int $back Display from this article ID at backwards scrolling (default: 0) + */ + function showIndirectLinks( $level, $target, $limit, $from = 0, $back = 0 ) { + $out = $this->getOutput(); + $dbr = wfGetDB( DB_REPLICA ); + + $hidelinks = $this->opts->getValue( 'hidelinks' ); + $hideredirs = $this->opts->getValue( 'hideredirs' ); + $hidetrans = $this->opts->getValue( 'hidetrans' ); + $hideimages = $target->getNamespace() != NS_FILE || $this->opts->getValue( 'hideimages' ); + + $fetchlinks = ( !$hidelinks || !$hideredirs ); + + // Build query conds in concert for all three tables... + $conds['pagelinks'] = [ + 'pl_namespace' => $target->getNamespace(), + 'pl_title' => $target->getDBkey(), + ]; + $conds['templatelinks'] = [ + 'tl_namespace' => $target->getNamespace(), + 'tl_title' => $target->getDBkey(), + ]; + $conds['imagelinks'] = [ + 'il_to' => $target->getDBkey(), + ]; + + $namespace = $this->opts->getValue( 'namespace' ); + $invert = $this->opts->getValue( 'invert' ); + $nsComparison = ( $invert ? '!= ' : '= ' ) . $dbr->addQuotes( $namespace ); + if ( is_int( $namespace ) ) { + $conds['pagelinks'][] = "pl_from_namespace $nsComparison"; + $conds['templatelinks'][] = "tl_from_namespace $nsComparison"; + $conds['imagelinks'][] = "il_from_namespace $nsComparison"; + } + + if ( $from ) { + $conds['templatelinks'][] = "tl_from >= $from"; + $conds['pagelinks'][] = "pl_from >= $from"; + $conds['imagelinks'][] = "il_from >= $from"; + } + + if ( $hideredirs ) { + $conds['pagelinks']['rd_from'] = null; + } elseif ( $hidelinks ) { + $conds['pagelinks'][] = 'rd_from is NOT NULL'; + } + + $queryFunc = function ( IDatabase $dbr, $table, $fromCol ) use ( + $conds, $target, $limit + ) { + // Read an extra row as an at-end check + $queryLimit = $limit + 1; + $on = [ + "rd_from = $fromCol", + 'rd_title' => $target->getDBkey(), + 'rd_interwiki = ' . $dbr->addQuotes( '' ) . ' OR rd_interwiki IS NULL' + ]; + $on['rd_namespace'] = $target->getNamespace(); + // Inner LIMIT is 2X in case of stale backlinks with wrong namespaces + $subQuery = $dbr->buildSelectSubquery( + [ $table, 'redirect', 'page' ], + [ $fromCol, 'rd_from' ], + $conds[$table], + __CLASS__ . '::showIndirectLinks', + // Force JOIN order per T106682 to avoid large filesorts + [ 'ORDER BY' => $fromCol, 'LIMIT' => 2 * $queryLimit, 'STRAIGHT_JOIN' ], + [ + 'page' => [ 'JOIN', "$fromCol = page_id" ], + 'redirect' => [ 'LEFT JOIN', $on ] + ] + ); + return $dbr->select( + [ 'page', 'temp_backlink_range' => $subQuery ], + [ 'page_id', 'page_namespace', 'page_title', 'rd_from', 'page_is_redirect' ], + [], + __CLASS__ . '::showIndirectLinks', + [ 'ORDER BY' => 'page_id', 'LIMIT' => $queryLimit ], + [ 'page' => [ 'JOIN', "$fromCol = page_id" ] ] + ); + }; + + if ( $fetchlinks ) { + $plRes = $queryFunc( $dbr, 'pagelinks', 'pl_from' ); + } + + if ( !$hidetrans ) { + $tlRes = $queryFunc( $dbr, 'templatelinks', 'tl_from' ); + } + + if ( !$hideimages ) { + $ilRes = $queryFunc( $dbr, 'imagelinks', 'il_from' ); + } + + if ( ( !$fetchlinks || !$plRes->numRows() ) + && ( $hidetrans || !$tlRes->numRows() ) + && ( $hideimages || !$ilRes->numRows() ) + ) { + if ( $level == 0 && !$this->including() ) { + $out->addHTML( $this->whatlinkshereForm() ); + + // Show filters only if there are links + if ( $hidelinks || $hidetrans || $hideredirs || $hideimages ) { + $out->addHTML( $this->getFilterPanel() ); + } + $msgKey = is_int( $namespace ) ? 'nolinkshere-ns' : 'nolinkshere'; + $link = $this->getLinkRenderer()->makeLink( + $this->target, + null, + [], + $this->target->isRedirect() ? [ 'redirect' => 'no' ] : [] + ); + + $errMsg = $this->msg( $msgKey ) + ->params( $this->target->getPrefixedText() ) + ->rawParams( $link ) + ->parseAsBlock(); + $out->addHTML( $errMsg ); + $out->setStatusCode( 404 ); + } + + return; + } + + // Read the rows into an array and remove duplicates + // templatelinks comes second so that the templatelinks row overwrites the + // pagelinks row, so we get (inclusion) rather than nothing + if ( $fetchlinks ) { + foreach ( $plRes as $row ) { + $row->is_template = 0; + $row->is_image = 0; + $rows[$row->page_id] = $row; + } + } + if ( !$hidetrans ) { + foreach ( $tlRes as $row ) { + $row->is_template = 1; + $row->is_image = 0; + $rows[$row->page_id] = $row; + } + } + if ( !$hideimages ) { + foreach ( $ilRes as $row ) { + $row->is_template = 0; + $row->is_image = 1; + $rows[$row->page_id] = $row; + } + } + + // Sort by key and then change the keys to 0-based indices + ksort( $rows ); + $rows = array_values( $rows ); + + $numRows = count( $rows ); + + // Work out the start and end IDs, for prev/next links + if ( $numRows > $limit ) { + // More rows available after these ones + // Get the ID from the last row in the result set + $nextId = $rows[$limit]->page_id; + // Remove undisplayed rows + $rows = array_slice( $rows, 0, $limit ); + } else { + // No more rows after + $nextId = false; + } + $prevId = $from; + + // use LinkBatch to make sure, that all required data (associated with Titles) + // is loaded in one query + $lb = new LinkBatch(); + foreach ( $rows as $row ) { + $lb->add( $row->page_namespace, $row->page_title ); + } + $lb->execute(); + + if ( $level == 0 && !$this->including() ) { + $out->addHTML( $this->whatlinkshereForm() ); + $out->addHTML( $this->getFilterPanel() ); + + $link = $this->getLinkRenderer()->makeLink( + $this->target, + null, + [], + $this->target->isRedirect() ? [ 'redirect' => 'no' ] : [] + ); + + $msg = $this->msg( 'linkshere' ) + ->params( $this->target->getPrefixedText() ) + ->rawParams( $link ) + ->parseAsBlock(); + $out->addHTML( $msg ); + + $prevnext = $this->getPrevNext( $prevId, $nextId ); + $out->addHTML( $prevnext ); + } + $out->addHTML( $this->listStart( $level ) ); + foreach ( $rows as $row ) { + $nt = Title::makeTitle( $row->page_namespace, $row->page_title ); + + if ( $row->rd_from && $level < 2 ) { + $out->addHTML( $this->listItem( $row, $nt, $target, true ) ); + $this->showIndirectLinks( + $level + 1, + $nt, + $this->getConfig()->get( 'MaxRedirectLinksRetrieved' ) + ); + $out->addHTML( Xml::closeElement( 'li' ) ); + } else { + $out->addHTML( $this->listItem( $row, $nt, $target ) ); + } + } + + $out->addHTML( $this->listEnd() ); + + if ( $level == 0 && !$this->including() ) { + $out->addHTML( $prevnext ); + } + } + + protected function listStart( $level ) { + return Xml::openElement( 'ul', ( $level ? [] : [ 'id' => 'mw-whatlinkshere-list' ] ) ); + } + + protected function listItem( $row, $nt, $target, $notClose = false ) { + $dirmark = $this->getLanguage()->getDirMark(); + + # local message cache + static $msgcache = null; + if ( $msgcache === null ) { + static $msgs = [ 'isredirect', 'istemplate', 'semicolon-separator', + 'whatlinkshere-links', 'isimage', 'editlink' ]; + $msgcache = []; + foreach ( $msgs as $msg ) { + $msgcache[$msg] = $this->msg( $msg )->escaped(); + } + } + + if ( $row->rd_from ) { + $query = [ 'redirect' => 'no' ]; + } else { + $query = []; + } + + $link = $this->getLinkRenderer()->makeKnownLink( + $nt, + null, + $row->page_is_redirect ? [ 'class' => 'mw-redirect' ] : [], + $query + ); + + // Display properties (redirect or template) + $propsText = ''; + $props = []; + if ( $row->rd_from ) { + $props[] = $msgcache['isredirect']; + } + if ( $row->is_template ) { + $props[] = $msgcache['istemplate']; + } + if ( $row->is_image ) { + $props[] = $msgcache['isimage']; + } + + Hooks::run( 'WhatLinksHereProps', [ $row, $nt, $target, &$props ] ); + + if ( count( $props ) ) { + $propsText = $this->msg( 'parentheses' ) + ->rawParams( implode( $msgcache['semicolon-separator'], $props ) )->escaped(); + } + + # Space for utilities links, with a what-links-here link provided + $wlhLink = $this->wlhLink( $nt, $msgcache['whatlinkshere-links'], $msgcache['editlink'] ); + $wlh = Xml::wrapClass( + $this->msg( 'parentheses' )->rawParams( $wlhLink )->escaped(), + 'mw-whatlinkshere-tools' + ); + + return $notClose ? + Xml::openElement( 'li' ) . "$link $propsText $dirmark $wlh\n" : + Xml::tags( 'li', null, "$link $propsText $dirmark $wlh" ) . "\n"; + } + + protected function listEnd() { + return Xml::closeElement( 'ul' ); + } + + protected function wlhLink( Title $target, $text, $editText ) { + static $title = null; + if ( $title === null ) { + $title = $this->getPageTitle(); + } + + $linkRenderer = $this->getLinkRenderer(); + + if ( $text !== null ) { + $text = new HtmlArmor( $text ); + } + + // always show a "<- Links" link + $links = [ + 'links' => $linkRenderer->makeKnownLink( + $title, + $text, + [], + [ 'target' => $target->getPrefixedText() ] + ), + ]; + + // if the page is editable, add an edit link + if ( + // check user permissions + $this->getUser()->isAllowed( 'edit' ) && + // check, if the content model is editable through action=edit + ContentHandler::getForTitle( $target )->supportsDirectEditing() + ) { + if ( $editText !== null ) { + $editText = new HtmlArmor( $editText ); + } + + $links['edit'] = $linkRenderer->makeKnownLink( + $target, + $editText, + [], + [ 'action' => 'edit' ] + ); + } + + // build the links html + return $this->getLanguage()->pipeList( $links ); + } + + function makeSelfLink( $text, $query ) { + if ( $text !== null ) { + $text = new HtmlArmor( $text ); + } + + return $this->getLinkRenderer()->makeKnownLink( + $this->selfTitle, + $text, + [], + $query + ); + } + + function getPrevNext( $prevId, $nextId ) { + $currentLimit = $this->opts->getValue( 'limit' ); + $prev = $this->msg( 'whatlinkshere-prev' )->numParams( $currentLimit )->escaped(); + $next = $this->msg( 'whatlinkshere-next' )->numParams( $currentLimit )->escaped(); + + $changed = $this->opts->getChangedValues(); + unset( $changed['target'] ); // Already in the request title + + if ( $prevId != 0 ) { + $overrides = [ 'from' => $this->opts->getValue( 'back' ) ]; + $prev = $this->makeSelfLink( $prev, array_merge( $changed, $overrides ) ); + } + if ( $nextId != 0 ) { + $overrides = [ 'from' => $nextId, 'back' => $prevId ]; + $next = $this->makeSelfLink( $next, array_merge( $changed, $overrides ) ); + } + + $limitLinks = []; + $lang = $this->getLanguage(); + foreach ( $this->limits as $limit ) { + $prettyLimit = htmlspecialchars( $lang->formatNum( $limit ) ); + $overrides = [ 'limit' => $limit ]; + $limitLinks[] = $this->makeSelfLink( $prettyLimit, array_merge( $changed, $overrides ) ); + } + + $nums = $lang->pipeList( $limitLinks ); + + return $this->msg( 'viewprevnext' )->rawParams( $prev, $next, $nums )->escaped(); + } + + function whatlinkshereForm() { + // We get nicer value from the title object + $this->opts->consumeValue( 'target' ); + // Reset these for new requests + $this->opts->consumeValues( [ 'back', 'from' ] ); + + $target = $this->target ? $this->target->getPrefixedText() : ''; + $namespace = $this->opts->consumeValue( 'namespace' ); + $nsinvert = $this->opts->consumeValue( 'invert' ); + + # Build up the form + $f = Xml::openElement( 'form', [ 'action' => wfScript() ] ); + + # Values that should not be forgotten + $f .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ); + foreach ( $this->opts->getUnconsumedValues() as $name => $value ) { + $f .= Html::hidden( $name, $value ); + } + + $f .= Xml::fieldset( $this->msg( 'whatlinkshere' )->text() ); + + # Target input (.mw-searchInput enables suggestions) + $f .= Xml::inputLabel( $this->msg( 'whatlinkshere-page' )->text(), 'target', + 'mw-whatlinkshere-target', 40, $target, [ 'class' => 'mw-searchInput' ] ); + + $f .= ' '; + + # Namespace selector + $f .= Html::namespaceSelector( + [ + 'selected' => $namespace, + 'all' => '', + 'label' => $this->msg( 'namespace' )->text(), + 'in-user-lang' => true, + ], [ + 'name' => 'namespace', + 'id' => 'namespace', + 'class' => 'namespaceselector', + ] + ); + + $f .= "\u{00A0}" . + Xml::checkLabel( + $this->msg( 'invert' )->text(), + 'invert', + 'nsinvert', + $nsinvert, + [ 'title' => $this->msg( 'tooltip-whatlinkshere-invert' )->text() ] + ); + + $f .= ' '; + + # Submit + $f .= Xml::submitButton( $this->msg( 'whatlinkshere-submit' )->text() ); + + # Close + $f .= Xml::closeElement( 'fieldset' ) . Xml::closeElement( 'form' ) . "\n"; + + return $f; + } + + /** + * Create filter panel + * + * @return string HTML fieldset and filter panel with the show/hide links + */ + function getFilterPanel() { + $show = $this->msg( 'show' )->escaped(); + $hide = $this->msg( 'hide' )->escaped(); + + $changed = $this->opts->getChangedValues(); + unset( $changed['target'] ); // Already in the request title + + $links = []; + $types = [ 'hidetrans', 'hidelinks', 'hideredirs' ]; + if ( $this->target->getNamespace() == NS_FILE ) { + $types[] = 'hideimages'; + } + + // Combined message keys: 'whatlinkshere-hideredirs', 'whatlinkshere-hidetrans', + // 'whatlinkshere-hidelinks', 'whatlinkshere-hideimages' + // To be sure they will be found by grep + foreach ( $types as $type ) { + $chosen = $this->opts->getValue( $type ); + $msg = $chosen ? $show : $hide; + $overrides = [ $type => !$chosen ]; + $links[] = $this->msg( "whatlinkshere-{$type}" )->rawParams( + $this->makeSelfLink( $msg, array_merge( $changed, $overrides ) ) )->escaped(); + } + + return Xml::fieldset( + $this->msg( 'whatlinkshere-filters' )->text(), + $this->getLanguage()->pipeList( $links ) + ); + } + + /** + * Return an array of subpages beginning with $search that this special page will accept. + * + * @param string $search Prefix to search for + * @param int $limit Maximum number of results to return (usually 10) + * @param int $offset Number of results to skip (usually 0) + * @return string[] Matching subpages + */ + public function prefixSearchSubpages( $search, $limit, $offset ) { + return $this->prefixSearchString( $search, $limit, $offset ); + } + + protected function getGroupName() { + return 'pagetools'; + } +} diff --git a/includes/specials/SpecialWhatlinkshere.php b/includes/specials/SpecialWhatlinkshere.php deleted file mode 100644 index 18c10bf706..0000000000 --- a/includes/specials/SpecialWhatlinkshere.php +++ /dev/null @@ -1,591 +0,0 @@ -getOutput(); - - $this->setHeaders(); - $this->outputHeader(); - $this->addHelpLink( 'Help:What links here' ); - - $opts = new FormOptions(); - - $opts->add( 'target', '' ); - $opts->add( 'namespace', '', FormOptions::INTNULL ); - $opts->add( 'limit', $this->getConfig()->get( 'QueryPageDefaultLimit' ) ); - $opts->add( 'from', 0 ); - $opts->add( 'back', 0 ); - $opts->add( 'hideredirs', false ); - $opts->add( 'hidetrans', false ); - $opts->add( 'hidelinks', false ); - $opts->add( 'hideimages', false ); - $opts->add( 'invert', false ); - - $opts->fetchValuesFromRequest( $this->getRequest() ); - $opts->validateIntBounds( 'limit', 0, 5000 ); - - // Give precedence to subpage syntax - if ( $par !== null ) { - $opts->setValue( 'target', $par ); - } - - // Bind to member variable - $this->opts = $opts; - - $this->target = Title::newFromText( $opts->getValue( 'target' ) ); - if ( !$this->target ) { - if ( !$this->including() ) { - $out->addHTML( $this->whatlinkshereForm() ); - } - - return; - } - - $this->getSkin()->setRelevantTitle( $this->target ); - - $this->selfTitle = $this->getPageTitle( $this->target->getPrefixedDBkey() ); - - $out->setPageTitle( $this->msg( 'whatlinkshere-title', $this->target->getPrefixedText() ) ); - $out->addBacklinkSubtitle( $this->target ); - $this->showIndirectLinks( - 0, - $this->target, - $opts->getValue( 'limit' ), - $opts->getValue( 'from' ), - $opts->getValue( 'back' ) - ); - } - - /** - * @param int $level Recursion level - * @param Title $target Target title - * @param int $limit Number of entries to display - * @param int $from Display from this article ID (default: 0) - * @param int $back Display from this article ID at backwards scrolling (default: 0) - */ - function showIndirectLinks( $level, $target, $limit, $from = 0, $back = 0 ) { - $out = $this->getOutput(); - $dbr = wfGetDB( DB_REPLICA ); - - $hidelinks = $this->opts->getValue( 'hidelinks' ); - $hideredirs = $this->opts->getValue( 'hideredirs' ); - $hidetrans = $this->opts->getValue( 'hidetrans' ); - $hideimages = $target->getNamespace() != NS_FILE || $this->opts->getValue( 'hideimages' ); - - $fetchlinks = ( !$hidelinks || !$hideredirs ); - - // Build query conds in concert for all three tables... - $conds['pagelinks'] = [ - 'pl_namespace' => $target->getNamespace(), - 'pl_title' => $target->getDBkey(), - ]; - $conds['templatelinks'] = [ - 'tl_namespace' => $target->getNamespace(), - 'tl_title' => $target->getDBkey(), - ]; - $conds['imagelinks'] = [ - 'il_to' => $target->getDBkey(), - ]; - - $namespace = $this->opts->getValue( 'namespace' ); - $invert = $this->opts->getValue( 'invert' ); - $nsComparison = ( $invert ? '!= ' : '= ' ) . $dbr->addQuotes( $namespace ); - if ( is_int( $namespace ) ) { - $conds['pagelinks'][] = "pl_from_namespace $nsComparison"; - $conds['templatelinks'][] = "tl_from_namespace $nsComparison"; - $conds['imagelinks'][] = "il_from_namespace $nsComparison"; - } - - if ( $from ) { - $conds['templatelinks'][] = "tl_from >= $from"; - $conds['pagelinks'][] = "pl_from >= $from"; - $conds['imagelinks'][] = "il_from >= $from"; - } - - if ( $hideredirs ) { - $conds['pagelinks']['rd_from'] = null; - } elseif ( $hidelinks ) { - $conds['pagelinks'][] = 'rd_from is NOT NULL'; - } - - $queryFunc = function ( IDatabase $dbr, $table, $fromCol ) use ( - $conds, $target, $limit - ) { - // Read an extra row as an at-end check - $queryLimit = $limit + 1; - $on = [ - "rd_from = $fromCol", - 'rd_title' => $target->getDBkey(), - 'rd_interwiki = ' . $dbr->addQuotes( '' ) . ' OR rd_interwiki IS NULL' - ]; - $on['rd_namespace'] = $target->getNamespace(); - // Inner LIMIT is 2X in case of stale backlinks with wrong namespaces - $subQuery = $dbr->buildSelectSubquery( - [ $table, 'redirect', 'page' ], - [ $fromCol, 'rd_from' ], - $conds[$table], - __CLASS__ . '::showIndirectLinks', - // Force JOIN order per T106682 to avoid large filesorts - [ 'ORDER BY' => $fromCol, 'LIMIT' => 2 * $queryLimit, 'STRAIGHT_JOIN' ], - [ - 'page' => [ 'JOIN', "$fromCol = page_id" ], - 'redirect' => [ 'LEFT JOIN', $on ] - ] - ); - return $dbr->select( - [ 'page', 'temp_backlink_range' => $subQuery ], - [ 'page_id', 'page_namespace', 'page_title', 'rd_from', 'page_is_redirect' ], - [], - __CLASS__ . '::showIndirectLinks', - [ 'ORDER BY' => 'page_id', 'LIMIT' => $queryLimit ], - [ 'page' => [ 'JOIN', "$fromCol = page_id" ] ] - ); - }; - - if ( $fetchlinks ) { - $plRes = $queryFunc( $dbr, 'pagelinks', 'pl_from' ); - } - - if ( !$hidetrans ) { - $tlRes = $queryFunc( $dbr, 'templatelinks', 'tl_from' ); - } - - if ( !$hideimages ) { - $ilRes = $queryFunc( $dbr, 'imagelinks', 'il_from' ); - } - - if ( ( !$fetchlinks || !$plRes->numRows() ) - && ( $hidetrans || !$tlRes->numRows() ) - && ( $hideimages || !$ilRes->numRows() ) - ) { - if ( $level == 0 && !$this->including() ) { - $out->addHTML( $this->whatlinkshereForm() ); - - // Show filters only if there are links - if ( $hidelinks || $hidetrans || $hideredirs || $hideimages ) { - $out->addHTML( $this->getFilterPanel() ); - } - $msgKey = is_int( $namespace ) ? 'nolinkshere-ns' : 'nolinkshere'; - $link = $this->getLinkRenderer()->makeLink( - $this->target, - null, - [], - $this->target->isRedirect() ? [ 'redirect' => 'no' ] : [] - ); - - $errMsg = $this->msg( $msgKey ) - ->params( $this->target->getPrefixedText() ) - ->rawParams( $link ) - ->parseAsBlock(); - $out->addHTML( $errMsg ); - $out->setStatusCode( 404 ); - } - - return; - } - - // Read the rows into an array and remove duplicates - // templatelinks comes second so that the templatelinks row overwrites the - // pagelinks row, so we get (inclusion) rather than nothing - if ( $fetchlinks ) { - foreach ( $plRes as $row ) { - $row->is_template = 0; - $row->is_image = 0; - $rows[$row->page_id] = $row; - } - } - if ( !$hidetrans ) { - foreach ( $tlRes as $row ) { - $row->is_template = 1; - $row->is_image = 0; - $rows[$row->page_id] = $row; - } - } - if ( !$hideimages ) { - foreach ( $ilRes as $row ) { - $row->is_template = 0; - $row->is_image = 1; - $rows[$row->page_id] = $row; - } - } - - // Sort by key and then change the keys to 0-based indices - ksort( $rows ); - $rows = array_values( $rows ); - - $numRows = count( $rows ); - - // Work out the start and end IDs, for prev/next links - if ( $numRows > $limit ) { - // More rows available after these ones - // Get the ID from the last row in the result set - $nextId = $rows[$limit]->page_id; - // Remove undisplayed rows - $rows = array_slice( $rows, 0, $limit ); - } else { - // No more rows after - $nextId = false; - } - $prevId = $from; - - // use LinkBatch to make sure, that all required data (associated with Titles) - // is loaded in one query - $lb = new LinkBatch(); - foreach ( $rows as $row ) { - $lb->add( $row->page_namespace, $row->page_title ); - } - $lb->execute(); - - if ( $level == 0 && !$this->including() ) { - $out->addHTML( $this->whatlinkshereForm() ); - $out->addHTML( $this->getFilterPanel() ); - - $link = $this->getLinkRenderer()->makeLink( - $this->target, - null, - [], - $this->target->isRedirect() ? [ 'redirect' => 'no' ] : [] - ); - - $msg = $this->msg( 'linkshere' ) - ->params( $this->target->getPrefixedText() ) - ->rawParams( $link ) - ->parseAsBlock(); - $out->addHTML( $msg ); - - $prevnext = $this->getPrevNext( $prevId, $nextId ); - $out->addHTML( $prevnext ); - } - $out->addHTML( $this->listStart( $level ) ); - foreach ( $rows as $row ) { - $nt = Title::makeTitle( $row->page_namespace, $row->page_title ); - - if ( $row->rd_from && $level < 2 ) { - $out->addHTML( $this->listItem( $row, $nt, $target, true ) ); - $this->showIndirectLinks( - $level + 1, - $nt, - $this->getConfig()->get( 'MaxRedirectLinksRetrieved' ) - ); - $out->addHTML( Xml::closeElement( 'li' ) ); - } else { - $out->addHTML( $this->listItem( $row, $nt, $target ) ); - } - } - - $out->addHTML( $this->listEnd() ); - - if ( $level == 0 && !$this->including() ) { - $out->addHTML( $prevnext ); - } - } - - protected function listStart( $level ) { - return Xml::openElement( 'ul', ( $level ? [] : [ 'id' => 'mw-whatlinkshere-list' ] ) ); - } - - protected function listItem( $row, $nt, $target, $notClose = false ) { - $dirmark = $this->getLanguage()->getDirMark(); - - # local message cache - static $msgcache = null; - if ( $msgcache === null ) { - static $msgs = [ 'isredirect', 'istemplate', 'semicolon-separator', - 'whatlinkshere-links', 'isimage', 'editlink' ]; - $msgcache = []; - foreach ( $msgs as $msg ) { - $msgcache[$msg] = $this->msg( $msg )->escaped(); - } - } - - if ( $row->rd_from ) { - $query = [ 'redirect' => 'no' ]; - } else { - $query = []; - } - - $link = $this->getLinkRenderer()->makeKnownLink( - $nt, - null, - $row->page_is_redirect ? [ 'class' => 'mw-redirect' ] : [], - $query - ); - - // Display properties (redirect or template) - $propsText = ''; - $props = []; - if ( $row->rd_from ) { - $props[] = $msgcache['isredirect']; - } - if ( $row->is_template ) { - $props[] = $msgcache['istemplate']; - } - if ( $row->is_image ) { - $props[] = $msgcache['isimage']; - } - - Hooks::run( 'WhatLinksHereProps', [ $row, $nt, $target, &$props ] ); - - if ( count( $props ) ) { - $propsText = $this->msg( 'parentheses' ) - ->rawParams( implode( $msgcache['semicolon-separator'], $props ) )->escaped(); - } - - # Space for utilities links, with a what-links-here link provided - $wlhLink = $this->wlhLink( $nt, $msgcache['whatlinkshere-links'], $msgcache['editlink'] ); - $wlh = Xml::wrapClass( - $this->msg( 'parentheses' )->rawParams( $wlhLink )->escaped(), - 'mw-whatlinkshere-tools' - ); - - return $notClose ? - Xml::openElement( 'li' ) . "$link $propsText $dirmark $wlh\n" : - Xml::tags( 'li', null, "$link $propsText $dirmark $wlh" ) . "\n"; - } - - protected function listEnd() { - return Xml::closeElement( 'ul' ); - } - - protected function wlhLink( Title $target, $text, $editText ) { - static $title = null; - if ( $title === null ) { - $title = $this->getPageTitle(); - } - - $linkRenderer = $this->getLinkRenderer(); - - if ( $text !== null ) { - $text = new HtmlArmor( $text ); - } - - // always show a "<- Links" link - $links = [ - 'links' => $linkRenderer->makeKnownLink( - $title, - $text, - [], - [ 'target' => $target->getPrefixedText() ] - ), - ]; - - // if the page is editable, add an edit link - if ( - // check user permissions - $this->getUser()->isAllowed( 'edit' ) && - // check, if the content model is editable through action=edit - ContentHandler::getForTitle( $target )->supportsDirectEditing() - ) { - if ( $editText !== null ) { - $editText = new HtmlArmor( $editText ); - } - - $links['edit'] = $linkRenderer->makeKnownLink( - $target, - $editText, - [], - [ 'action' => 'edit' ] - ); - } - - // build the links html - return $this->getLanguage()->pipeList( $links ); - } - - function makeSelfLink( $text, $query ) { - if ( $text !== null ) { - $text = new HtmlArmor( $text ); - } - - return $this->getLinkRenderer()->makeKnownLink( - $this->selfTitle, - $text, - [], - $query - ); - } - - function getPrevNext( $prevId, $nextId ) { - $currentLimit = $this->opts->getValue( 'limit' ); - $prev = $this->msg( 'whatlinkshere-prev' )->numParams( $currentLimit )->escaped(); - $next = $this->msg( 'whatlinkshere-next' )->numParams( $currentLimit )->escaped(); - - $changed = $this->opts->getChangedValues(); - unset( $changed['target'] ); // Already in the request title - - if ( $prevId != 0 ) { - $overrides = [ 'from' => $this->opts->getValue( 'back' ) ]; - $prev = $this->makeSelfLink( $prev, array_merge( $changed, $overrides ) ); - } - if ( $nextId != 0 ) { - $overrides = [ 'from' => $nextId, 'back' => $prevId ]; - $next = $this->makeSelfLink( $next, array_merge( $changed, $overrides ) ); - } - - $limitLinks = []; - $lang = $this->getLanguage(); - foreach ( $this->limits as $limit ) { - $prettyLimit = htmlspecialchars( $lang->formatNum( $limit ) ); - $overrides = [ 'limit' => $limit ]; - $limitLinks[] = $this->makeSelfLink( $prettyLimit, array_merge( $changed, $overrides ) ); - } - - $nums = $lang->pipeList( $limitLinks ); - - return $this->msg( 'viewprevnext' )->rawParams( $prev, $next, $nums )->escaped(); - } - - function whatlinkshereForm() { - // We get nicer value from the title object - $this->opts->consumeValue( 'target' ); - // Reset these for new requests - $this->opts->consumeValues( [ 'back', 'from' ] ); - - $target = $this->target ? $this->target->getPrefixedText() : ''; - $namespace = $this->opts->consumeValue( 'namespace' ); - $nsinvert = $this->opts->consumeValue( 'invert' ); - - # Build up the form - $f = Xml::openElement( 'form', [ 'action' => wfScript() ] ); - - # Values that should not be forgotten - $f .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ); - foreach ( $this->opts->getUnconsumedValues() as $name => $value ) { - $f .= Html::hidden( $name, $value ); - } - - $f .= Xml::fieldset( $this->msg( 'whatlinkshere' )->text() ); - - # Target input (.mw-searchInput enables suggestions) - $f .= Xml::inputLabel( $this->msg( 'whatlinkshere-page' )->text(), 'target', - 'mw-whatlinkshere-target', 40, $target, [ 'class' => 'mw-searchInput' ] ); - - $f .= ' '; - - # Namespace selector - $f .= Html::namespaceSelector( - [ - 'selected' => $namespace, - 'all' => '', - 'label' => $this->msg( 'namespace' )->text(), - 'in-user-lang' => true, - ], [ - 'name' => 'namespace', - 'id' => 'namespace', - 'class' => 'namespaceselector', - ] - ); - - $f .= "\u{00A0}" . - Xml::checkLabel( - $this->msg( 'invert' )->text(), - 'invert', - 'nsinvert', - $nsinvert, - [ 'title' => $this->msg( 'tooltip-whatlinkshere-invert' )->text() ] - ); - - $f .= ' '; - - # Submit - $f .= Xml::submitButton( $this->msg( 'whatlinkshere-submit' )->text() ); - - # Close - $f .= Xml::closeElement( 'fieldset' ) . Xml::closeElement( 'form' ) . "\n"; - - return $f; - } - - /** - * Create filter panel - * - * @return string HTML fieldset and filter panel with the show/hide links - */ - function getFilterPanel() { - $show = $this->msg( 'show' )->escaped(); - $hide = $this->msg( 'hide' )->escaped(); - - $changed = $this->opts->getChangedValues(); - unset( $changed['target'] ); // Already in the request title - - $links = []; - $types = [ 'hidetrans', 'hidelinks', 'hideredirs' ]; - if ( $this->target->getNamespace() == NS_FILE ) { - $types[] = 'hideimages'; - } - - // Combined message keys: 'whatlinkshere-hideredirs', 'whatlinkshere-hidetrans', - // 'whatlinkshere-hidelinks', 'whatlinkshere-hideimages' - // To be sure they will be found by grep - foreach ( $types as $type ) { - $chosen = $this->opts->getValue( $type ); - $msg = $chosen ? $show : $hide; - $overrides = [ $type => !$chosen ]; - $links[] = $this->msg( "whatlinkshere-{$type}" )->rawParams( - $this->makeSelfLink( $msg, array_merge( $changed, $overrides ) ) )->escaped(); - } - - return Xml::fieldset( - $this->msg( 'whatlinkshere-filters' )->text(), - $this->getLanguage()->pipeList( $links ) - ); - } - - /** - * Return an array of subpages beginning with $search that this special page will accept. - * - * @param string $search Prefix to search for - * @param int $limit Maximum number of results to return (usually 10) - * @param int $offset Number of results to skip (usually 0) - * @return string[] Matching subpages - */ - public function prefixSearchSubpages( $search, $limit, $offset ) { - return $this->prefixSearchString( $search, $limit, $offset ); - } - - protected function getGroupName() { - return 'pagetools'; - } -}