diff options
author | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
---|---|---|
committer | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
commit | fc7369835258467bf97eb64f184b93691f9a9fd5 (patch) | |
tree | daabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/includes/specials |
first commit
Diffstat (limited to 'www/wiki/includes/specials')
139 files changed, 36002 insertions, 0 deletions
diff --git a/www/wiki/includes/specials/SpecialActiveusers.php b/www/wiki/includes/specials/SpecialActiveusers.php new file mode 100644 index 00000000..90287878 --- /dev/null +++ b/www/wiki/includes/specials/SpecialActiveusers.php @@ -0,0 +1,172 @@ +<?php +/** + * Implements Special:Activeusers + * + * 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 + */ + +/** + * Implements Special:Activeusers + * + * @ingroup SpecialPage + */ +class SpecialActiveUsers extends SpecialPage { + + public function __construct() { + parent::__construct( 'Activeusers' ); + } + + /** + * Show the special page + * + * @param string $par Parameter passed to the page or null + */ + public function execute( $par ) { + $out = $this->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(); + + foreach ( $groups as $group ) { + $msg = htmlspecialchars( UserGroupMembership::getGroupName( $group ) ); + $options[$msg] = $group; + } + + // 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/www/wiki/includes/specials/SpecialAllMessages.php b/www/wiki/includes/specials/SpecialAllMessages.php new file mode 100644 index 00000000..9e66447f --- /dev/null +++ b/www/wiki/includes/specials/SpecialAllMessages.php @@ -0,0 +1,74 @@ +<?php +/** + * Implements Special:Allmessages + * + * 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 + */ + +/** + * Use this special page to get a list of the MediaWiki system messages. + * + * @file + * @ingroup SpecialPage + */ +class SpecialAllMessages extends SpecialPage { + /** + * @var AllMessagesTablePager + */ + protected $table; + + public function __construct() { + parent::__construct( 'Allmessages' ); + } + + /** + * Show the special page + * + * @param string $par Parameter passed to the page or null + */ + public function execute( $par ) { + $request = $this->getRequest(); + $out = $this->getOutput(); + + $this->setHeaders(); + + if ( !$this->getConfig()->get( 'UseDatabaseMessages' ) ) { + $out->addWikiMsg( 'allmessagesnotsupportedDB' ); + + return; + } + + $this->outputHeader( 'allmessagestext' ); + $out->addModuleStyles( 'mediawiki.special' ); + $this->addHelpLink( 'Help:System message' ); + + $this->table = new AllMessagesTablePager( + $this, + [], + wfGetLangObj( $request->getVal( 'lang', $par ) ) + ); + + $out->addHTML( $this->table->buildForm() ); + $out->addParserOutputContent( $this->table->getFullOutput() ); + } + + protected function getGroupName() { + return 'wiki'; + } +} diff --git a/www/wiki/includes/specials/SpecialAllPages.php b/www/wiki/includes/specials/SpecialAllPages.php new file mode 100644 index 00000000..f9c917d3 --- /dev/null +++ b/www/wiki/includes/specials/SpecialAllPages.php @@ -0,0 +1,384 @@ +<?php +/** + * Implements Special:Allpages + * + * 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 + */ + +/** + * Implements Special:Allpages + * + * @ingroup SpecialPage + * @todo Rewrite using IndexPager + */ +class SpecialAllPages extends IncludableSpecialPage { + + /** + * Maximum number of pages to show on single subpage. + * + * @var int $maxPerPage + */ + protected $maxPerPage = 345; + + /** + * Determines, which message describes the input field 'nsfrom'. + * + * @var string $nsfromMsg + */ + protected $nsfromMsg = 'allpagesfrom'; + + /** + * @param string $name Name of the special page, as seen in links and URLs (default: 'Allpages') + */ + function __construct( $name = 'Allpages' ) { + parent::__construct( $name ); + } + + /** + * Entry point : initialise variables and call subfunctions. + * + * @param string $par Becomes "FOO" when called like Special:Allpages/FOO (default null) + */ + function execute( $par ) { + $request = $this->getRequest(); + $out = $this->getOutput(); + + $this->setHeaders(); + $this->outputHeader(); + $out->allowClickjacking(); + + # GET values + $from = $request->getVal( 'from', null ); + $to = $request->getVal( 'to', null ); + $namespace = $request->getInt( 'namespace' ); + + $miserMode = (bool)$this->getConfig()->get( 'MiserMode' ); + + // Redirects filter is disabled in MiserMode + $hideredirects = $request->getBool( 'hideredirects', false ) && !$miserMode; + + $namespaces = $this->getLanguage()->getNamespaces(); + + $out->setPageTitle( + ( $namespace > 0 && array_key_exists( $namespace, $namespaces ) ) ? + $this->msg( 'allinnamespace', str_replace( '_', ' ', $namespaces[$namespace] ) ) : + $this->msg( 'allarticles' ) + ); + $out->addModuleStyles( 'mediawiki.special' ); + + if ( $par !== null ) { + $this->showChunk( $namespace, $par, $to, $hideredirects ); + } elseif ( $from !== null && $to === null ) { + $this->showChunk( $namespace, $from, $to, $hideredirects ); + } else { + $this->showToplevel( $namespace, $from, $to, $hideredirects ); + } + } + + /** + * Outputs the HTMLForm used on this page + * + * @param int $namespace A namespace constant (default NS_MAIN). + * @param string $from DbKey we are starting listing at. + * @param string $to DbKey we are ending listing at. + * @param bool $hideRedirects Dont show redirects (default false) + */ + protected function outputHTMLForm( $namespace = NS_MAIN, + $from = '', $to = '', $hideRedirects = false + ) { + $miserMode = (bool)$this->getConfig()->get( 'MiserMode' ); + $fields = [ + 'from' => [ + 'type' => 'text', + 'name' => 'from', + 'id' => 'nsfrom', + 'size' => 30, + 'label-message' => 'allpagesfrom', + 'default' => str_replace( '_', ' ', $from ), + ], + 'to' => [ + 'type' => 'text', + 'name' => 'to', + 'id' => 'nsto', + 'size' => 30, + 'label-message' => 'allpagesto', + 'default' => str_replace( '_', ' ', $to ), + ], + 'namespace' => [ + 'type' => 'namespaceselect', + 'name' => 'namespace', + 'id' => 'namespace', + 'label-message' => 'namespace', + 'all' => null, + 'value' => $namespace, + ], + 'hideredirects' => [ + 'type' => 'check', + 'name' => 'hideredirects', + 'id' => 'hidredirects', + 'label-message' => 'allpages-hide-redirects', + 'value' => $hideRedirects, + ], + ]; + + if ( $miserMode ) { + unset( $fields['hideredirects'] ); + } + + $form = HTMLForm::factory( 'table', $fields, $this->getContext() ); + $form->setMethod( 'get' ) + ->setWrapperLegendMsg( 'allpages' ) + ->setSubmitTextMsg( 'allpagessubmit' ) + ->prepareForm() + ->displayForm( false ); + } + + /** + * @param int $namespace (default NS_MAIN) + * @param string $from List all pages from this name + * @param string $to List all pages to this name + * @param bool $hideredirects Dont show redirects (default false) + */ + function showToplevel( $namespace = NS_MAIN, $from = '', $to = '', $hideredirects = false ) { + $from = Title::makeTitleSafe( $namespace, $from ); + $to = Title::makeTitleSafe( $namespace, $to ); + $from = ( $from && $from->isLocal() ) ? $from->getDBkey() : null; + $to = ( $to && $to->isLocal() ) ? $to->getDBkey() : null; + + $this->showChunk( $namespace, $from, $to, $hideredirects ); + } + + /** + * @param int $namespace Namespace (Default NS_MAIN) + * @param string $from List all pages from this name (default false) + * @param string $to List all pages to this name (default false) + * @param bool $hideredirects Dont show redirects (default false) + */ + function showChunk( $namespace = NS_MAIN, $from = false, $to = false, $hideredirects = false ) { + $output = $this->getOutput(); + + $fromList = $this->getNamespaceKeyAndText( $namespace, $from ); + $toList = $this->getNamespaceKeyAndText( $namespace, $to ); + $namespaces = $this->getContext()->getLanguage()->getNamespaces(); + $n = 0; + $prevTitle = null; + + if ( !$fromList || !$toList ) { + $out = $this->msg( 'allpagesbadtitle' )->parseAsBlock(); + } elseif ( !array_key_exists( $namespace, $namespaces ) ) { + // Show errormessage and reset to NS_MAIN + $out = $this->msg( 'allpages-bad-ns', $namespace )->parse(); + $namespace = NS_MAIN; + } else { + list( $namespace, $fromKey, $from ) = $fromList; + list( , $toKey, $to ) = $toList; + + $dbr = wfGetDB( DB_REPLICA ); + $filterConds = [ 'page_namespace' => $namespace ]; + if ( $hideredirects ) { + $filterConds['page_is_redirect'] = 0; + } + + $conds = $filterConds; + $conds[] = 'page_title >= ' . $dbr->addQuotes( $fromKey ); + if ( $toKey !== "" ) { + $conds[] = 'page_title <= ' . $dbr->addQuotes( $toKey ); + } + + $res = $dbr->select( 'page', + [ 'page_namespace', 'page_title', 'page_is_redirect', 'page_id' ], + $conds, + __METHOD__, + [ + 'ORDER BY' => 'page_title', + 'LIMIT' => $this->maxPerPage + 1, + 'USE INDEX' => 'name_title', + ] + ); + + $linkRenderer = $this->getLinkRenderer(); + if ( $res->numRows() > 0 ) { + $out = Html::openElement( 'ul', [ 'class' => 'mw-allpages-chunk' ] ); + + while ( ( $n < $this->maxPerPage ) && ( $s = $res->fetchObject() ) ) { + $t = Title::newFromRow( $s ); + if ( $t ) { + $out .= '<li' . + ( $s->page_is_redirect ? ' class="allpagesredirect"' : '' ) . + '>' . + $linkRenderer->makeLink( $t ) . + "</li>\n"; + } else { + $out .= '<li>[[' . htmlspecialchars( $s->page_title ) . "]]</li>\n"; + } + $n++; + } + $out .= Html::closeElement( 'ul' ); + + if ( $res->numRows() > 2 ) { + // Only apply CSS column styles if there's more than 2 entries. + // Otherwise, rendering is broken as "mw-allpages-body"'s CSS column count is 3. + $out = Html::rawElement( 'div', [ 'class' => 'mw-allpages-body' ], $out ); + } + } else { + $out = ''; + } + + if ( $fromKey !== '' && !$this->including() ) { + # Get the first title from previous chunk + $prevConds = $filterConds; + $prevConds[] = 'page_title < ' . $dbr->addQuotes( $fromKey ); + $prevKey = $dbr->selectField( + 'page', + 'page_title', + $prevConds, + __METHOD__, + [ 'ORDER BY' => 'page_title DESC', 'OFFSET' => $this->maxPerPage - 1 ] + ); + + if ( $prevKey === false ) { + # The previous chunk is not complete, need to link to the very first title + # available in the database + $prevKey = $dbr->selectField( + 'page', + 'page_title', + $prevConds, + __METHOD__, + [ 'ORDER BY' => 'page_title' ] + ); + } + + if ( $prevKey !== false ) { + $prevTitle = Title::makeTitle( $namespace, $prevKey ); + } + } + } + + if ( $this->including() ) { + $output->addHTML( $out ); + return; + } + + $navLinks = []; + $self = $this->getPageTitle(); + + $linkRenderer = $this->getLinkRenderer(); + // Generate a "previous page" link if needed + if ( $prevTitle ) { + $query = [ 'from' => $prevTitle->getText() ]; + + if ( $namespace ) { + $query['namespace'] = $namespace; + } + + if ( $hideredirects ) { + $query['hideredirects'] = $hideredirects; + } + + $navLinks[] = $linkRenderer->makeKnownLink( + $self, + $this->msg( 'prevpage', $prevTitle->getText() )->text(), + [], + $query + ); + + } + + // Generate a "next page" link if needed + if ( $n == $this->maxPerPage && $s = $res->fetchObject() ) { + # $s is the first link of the next chunk + $t = Title::makeTitle( $namespace, $s->page_title ); + $query = [ 'from' => $t->getText() ]; + + if ( $namespace ) { + $query['namespace'] = $namespace; + } + + if ( $hideredirects ) { + $query['hideredirects'] = $hideredirects; + } + + $navLinks[] = $linkRenderer->makeKnownLink( + $self, + $this->msg( 'nextpage', $t->getText() )->text(), + [], + $query + ); + } + + $this->outputHTMLForm( $namespace, $from, $to, $hideredirects ); + + if ( count( $navLinks ) ) { + // Add pagination links + $pagination = Html::rawElement( 'div', + [ 'class' => 'mw-allpages-nav' ], + $this->getLanguage()->pipeList( $navLinks ) + ); + + $output->addHTML( $pagination ); + $out .= Html::element( 'hr' ) . $pagination; // Footer + } + + $output->addHTML( $out ); + } + + /** + * @param int $ns The namespace of the article + * @param string $text The name of the article + * @return array|null [ int namespace, string dbkey, string pagename ] or null on error + */ + protected function getNamespaceKeyAndText( $ns, $text ) { + if ( $text == '' ) { + # shortcut for common case + return [ $ns, '', '' ]; + } + + $t = Title::makeTitleSafe( $ns, $text ); + if ( $t && $t->isLocal() ) { + return [ $t->getNamespace(), $t->getDBkey(), $t->getText() ]; + } elseif ( $t ) { + return null; + } + + # try again, in case the problem was an empty pagename + $text = preg_replace( '/(#|$)/', 'X$1', $text ); + $t = Title::makeTitleSafe( $ns, $text ); + if ( $t && $t->isLocal() ) { + return [ $t->getNamespace(), '', '' ]; + } else { + return null; + } + } + + /** + * 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 'pages'; + } +} diff --git a/www/wiki/includes/specials/SpecialAncientpages.php b/www/wiki/includes/specials/SpecialAncientpages.php new file mode 100644 index 00000000..ecc030e6 --- /dev/null +++ b/www/wiki/includes/specials/SpecialAncientpages.php @@ -0,0 +1,93 @@ +<?php +/** + * Implements Special:Ancientpages + * + * 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 + */ + +/** + * Implements Special:Ancientpages + * + * @ingroup SpecialPage + */ +class AncientPagesPage extends QueryPage { + + function __construct( $name = 'Ancientpages' ) { + parent::__construct( $name ); + } + + public function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + public function getQueryInfo() { + return [ + 'tables' => [ 'page', 'revision' ], + 'fields' => [ + 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'rev_timestamp' + ], + 'conds' => [ + 'page_namespace' => MWNamespace::getContentNamespaces(), + 'page_is_redirect' => 0, + 'page_latest=rev_id' + ] + ]; + } + + public function usesTimestamps() { + return true; + } + + function sortDescending() { + return false; + } + + public function preprocessResults( $db, $res ) { + $this->executeLBFromResultWrapper( $res ); + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + global $wgContLang; + + $d = $this->getLanguage()->userTimeAndDate( $result->value, $this->getUser() ); + $title = Title::makeTitle( $result->namespace, $result->title ); + $linkRenderer = $this->getLinkRenderer(); + $link = $linkRenderer->makeKnownLink( + $title, + $wgContLang->convert( $title->getPrefixedText() ) + ); + + return $this->getLanguage()->specialList( $link, htmlspecialchars( $d ) ); + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialApiHelp.php b/www/wiki/includes/specials/SpecialApiHelp.php new file mode 100644 index 00000000..54480132 --- /dev/null +++ b/www/wiki/includes/specials/SpecialApiHelp.php @@ -0,0 +1,98 @@ +<?php +/** + * Implements Special:ApiHelp + * + * 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 + */ + +/** + * Special page to redirect to API help pages, for situations where linking to + * the api.php endpoint is not wanted. + * + * @ingroup SpecialPage + */ +class SpecialApiHelp extends UnlistedSpecialPage { + public function __construct() { + parent::__construct( 'ApiHelp' ); + } + + public function execute( $par ) { + if ( empty( $par ) ) { + $par = 'main'; + } + + // These come from transclusions + $request = $this->getRequest(); + $options = [ + 'action' => 'help', + 'nolead' => true, + 'submodules' => $request->getCheck( 'submodules' ), + 'recursivesubmodules' => $request->getCheck( 'recursivesubmodules' ), + 'title' => $request->getVal( 'title', $this->getPageTitle( '$1' )->getPrefixedText() ), + ]; + + // These are for linking from wikitext, since url parameters are a pain + // to do. + while ( true ) { + if ( substr( $par, 0, 4 ) === 'sub/' ) { + $par = substr( $par, 4 ); + $options['submodules'] = 1; + continue; + } + + if ( substr( $par, 0, 5 ) === 'rsub/' ) { + $par = substr( $par, 5 ); + $options['recursivesubmodules'] = 1; + continue; + } + + $moduleName = $par; + break; + } + + if ( !$this->including() ) { + unset( $options['nolead'], $options['title'] ); + $options['modules'] = $moduleName; + $link = wfAppendQuery( wfExpandUrl( wfScript( 'api' ), PROTO_CURRENT ), $options ); + $this->getOutput()->redirect( $link ); + return; + } + + $main = new ApiMain( $this->getContext(), false ); + try { + $module = $main->getModuleFromPath( $moduleName ); + } catch ( ApiUsageException $ex ) { + $this->getOutput()->addHTML( Html::rawElement( 'span', [ 'class' => 'error' ], + $this->msg( 'apihelp-no-such-module', $moduleName )->inContentLanguage()->parse() + ) ); + return; + } catch ( UsageException $ex ) { + $this->getOutput()->addHTML( Html::rawElement( 'span', [ 'class' => 'error' ], + $this->msg( 'apihelp-no-such-module', $moduleName )->inContentLanguage()->parse() + ) ); + return; + } + + ApiHelp::getHelp( $this->getContext(), $module, $options ); + } + + public function isIncludable() { + return true; + } +} diff --git a/www/wiki/includes/specials/SpecialApiSandbox.php b/www/wiki/includes/specials/SpecialApiSandbox.php new file mode 100644 index 00000000..2733e757 --- /dev/null +++ b/www/wiki/includes/specials/SpecialApiSandbox.php @@ -0,0 +1,59 @@ +<?php +/** + * Implements Special:ApiSandbox + * + * 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 + * @since 1.27 + */ +class SpecialApiSandbox extends SpecialPage { + public function __construct() { + parent::__construct( 'ApiSandbox' ); + } + + public function execute( $par ) { + $this->setHeaders(); + $out = $this->getOutput(); + $this->addHelpLink( 'Help:ApiSandbox' ); + + if ( !$this->getConfig()->get( 'EnableAPI' ) ) { + $out->showErrorPage( 'error', 'apisandbox-api-disabled' ); + } + + $out->addJsConfigVars( 'apihighlimits', $this->getUser()->isAllowed( 'apihighlimits' ) ); + $out->addModuleStyles( [ + 'mediawiki.special.apisandbox.styles', + ] ); + $out->addModules( [ + 'mediawiki.special.apisandbox', + 'mediawiki.apipretty', + ] ); + $out->wrapWikiMsg( + "<div id='mw-apisandbox'><div class='mw-apisandbox-nojs error'>\n$1\n</div></div>", + 'apisandbox-jsonly' + ); + } + + protected function getGroupName() { + return 'wiki'; + } +} diff --git a/www/wiki/includes/specials/SpecialAutoblockList.php b/www/wiki/includes/specials/SpecialAutoblockList.php new file mode 100644 index 00000000..bf138656 --- /dev/null +++ b/www/wiki/includes/specials/SpecialAutoblockList.php @@ -0,0 +1,167 @@ +<?php +/** + * Implements Special:AutoblockList + * + * 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 + */ + +/** + * A special page that lists autoblocks + * + * @since 1.29 + * @ingroup SpecialPage + */ +class SpecialAutoblockList extends SpecialPage { + + function __construct() { + parent::__construct( 'AutoblockList' ); + } + + /** + * Main execution point + * + * @param string $par Title fragment + */ + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + $out = $this->getOutput(); + $lang = $this->getLanguage(); + $out->setPageTitle( $this->msg( 'autoblocklist' ) ); + $this->addHelpLink( 'Autoblock' ); + $out->addModuleStyles( [ 'mediawiki.special' ] ); + + # setup BlockListPager here to get the actual default Limit + $pager = $this->getBlockListPager(); + + # Just show the block list + $fields = [ + 'Limit' => [ + 'type' => 'limitselect', + 'label-message' => 'table_pager_limit_label', + 'options' => [ + $lang->formatNum( 20 ) => 20, + $lang->formatNum( 50 ) => 50, + $lang->formatNum( 100 ) => 100, + $lang->formatNum( 250 ) => 250, + $lang->formatNum( 500 ) => 500, + ], + 'name' => 'limit', + 'default' => $pager->getLimit(), + ] + ]; + + $context = new DerivativeContext( $this->getContext() ); + $context->setTitle( $this->getPageTitle() ); // Remove subpage + $form = HTMLForm::factory( 'ooui', $fields, $context ); + $form->setMethod( 'get' ) + ->setFormIdentifier( 'blocklist' ) + ->setWrapperLegendMsg( 'autoblocklist-legend' ) + ->setSubmitTextMsg( 'autoblocklist-submit' ) + ->setSubmitProgressive() + ->prepareForm() + ->displayForm( false ); + + $this->showTotal( $pager ); + $this->showList( $pager ); + } + + /** + * Setup a new BlockListPager instance. + * @return BlockListPager + */ + protected function getBlockListPager() { + $conds = [ + 'ipb_parent_block_id IS NOT NULL' + ]; + # Is the user allowed to see hidden blocks? + if ( !$this->getUser()->isAllowed( 'hideuser' ) ) { + $conds['ipb_deleted'] = 0; + } + + return new BlockListPager( $this, $conds ); + } + + /** + * Show total number of autoblocks on top of the table + * + * @param BlockListPager $pager The BlockListPager instance for this page + */ + protected function showTotal( BlockListPager $pager ) { + $out = $this->getOutput(); + $out->addHTML( + Html::element( 'div', [ 'style' => 'font-weight: bold;' ], + $this->msg( 'autoblocklist-total-autoblocks', $pager->getTotalAutoblocks() )->parse() ) + . "\n" + ); + } + + /** + * Show the list of blocked accounts matching the actual filter. + * @param BlockListPager $pager The BlockListPager instance for this page + */ + protected function showList( BlockListPager $pager ) { + $out = $this->getOutput(); + + # Check for other blocks, i.e. global/tor blocks + $otherAutoblockLink = []; + Hooks::run( 'OtherAutoblockLogLink', [ &$otherAutoblockLink ] ); + + # Show additional header for the local block only when other blocks exists. + # Not necessary in a standard installation without such extensions enabled + if ( count( $otherAutoblockLink ) ) { + $out->addHTML( + Html::element( 'h2', [], $this->msg( 'autoblocklist-localblocks', + $pager->getNumRows() )->parse() ) + . "\n" + ); + } + + if ( $pager->getNumRows() ) { + $out->addParserOutputContent( $pager->getFullOutput() ); + } else { + $out->addWikiMsg( 'autoblocklist-empty' ); + } + + if ( count( $otherAutoblockLink ) ) { + $out->addHTML( + Html::rawElement( + 'h2', + [], + $this->msg( 'autoblocklist-otherblocks', count( $otherAutoblockLink ) )->parse() + ) . "\n" + ); + $list = ''; + foreach ( $otherAutoblockLink as $link ) { + $list .= Html::rawElement( 'li', [], $link ) . "\n"; + } + $out->addHTML( + Html::rawElement( + 'ul', + [ 'class' => 'mw-autoblocklist-otherblocks' ], + $list + ) . "\n" + ); + } + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/www/wiki/includes/specials/SpecialBlankpage.php b/www/wiki/includes/specials/SpecialBlankpage.php new file mode 100644 index 00000000..e61f12b9 --- /dev/null +++ b/www/wiki/includes/specials/SpecialBlankpage.php @@ -0,0 +1,39 @@ +<?php +/** + * Implements Special:Blankpage + * + * 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 + */ + +/** + * Special page designed for basic benchmarking of + * MediaWiki since it doesn't really do much. + * + * @ingroup SpecialPage + */ +class SpecialBlankpage extends UnlistedSpecialPage { + public function __construct() { + parent::__construct( 'Blankpage' ); + } + + public function execute( $par ) { + $this->setHeaders(); + $this->getOutput()->addWikiMsg( 'intentionallyblankpage' ); + } +} diff --git a/www/wiki/includes/specials/SpecialBlock.php b/www/wiki/includes/specials/SpecialBlock.php new file mode 100644 index 00000000..23691b25 --- /dev/null +++ b/www/wiki/includes/specials/SpecialBlock.php @@ -0,0 +1,1038 @@ +<?php +/** + * Implements Special:Block + * + * 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 + */ + +/** + * A special page that allows users with 'block' right to block users from + * editing pages and other actions + * + * @ingroup SpecialPage + */ +class SpecialBlock extends FormSpecialPage { + /** @var User|string|null User to be blocked, as passed either by parameter (url?wpTarget=Foo) + * or as subpage (Special:Block/Foo) */ + protected $target; + + /** @var int Block::TYPE_ constant */ + protected $type; + + /** @var User|string The previous block target */ + protected $previousTarget; + + /** @var bool Whether the previous submission of the form asked for HideUser */ + protected $requestedHideUser; + + /** @var bool */ + protected $alreadyBlocked; + + /** @var array */ + protected $preErrors = []; + + public function __construct() { + parent::__construct( 'Block', 'block' ); + } + + public function doesWrites() { + return true; + } + + /** + * Checks that the user can unblock themselves if they are trying to do so + * + * @param User $user + * @throws ErrorPageError + */ + protected function checkExecutePermissions( User $user ) { + parent::checkExecutePermissions( $user ); + + # T17810: blocked admins should have limited access here + $status = self::checkUnblockSelf( $this->target, $user ); + if ( $status !== true ) { + throw new ErrorPageError( 'badaccess', $status ); + } + } + + /** + * Handle some magic here + * + * @param string $par + */ + protected function setParameter( $par ) { + # Extract variables from the request. Try not to get into a situation where we + # need to extract *every* variable from the form just for processing here, but + # there are legitimate uses for some variables + $request = $this->getRequest(); + list( $this->target, $this->type ) = self::getTargetAndType( $par, $request ); + if ( $this->target instanceof User ) { + # Set the 'relevant user' in the skin, so it displays links like Contributions, + # User logs, UserRights, etc. + $this->getSkin()->setRelevantUser( $this->target ); + } + + list( $this->previousTarget, /*...*/ ) = + Block::parseTarget( $request->getVal( 'wpPreviousTarget' ) ); + $this->requestedHideUser = $request->getBool( 'wpHideUser' ); + } + + /** + * Customizes the HTMLForm a bit + * + * @param HTMLForm $form + */ + protected function alterForm( HTMLForm $form ) { + $form->setHeaderText( '' ); + $form->setSubmitDestructive(); + + $msg = $this->alreadyBlocked ? 'ipb-change-block' : 'ipbsubmit'; + $form->setSubmitTextMsg( $msg ); + + $this->addHelpLink( 'Help:Blocking users' ); + + # Don't need to do anything if the form has been posted + if ( !$this->getRequest()->wasPosted() && $this->preErrors ) { + $s = $form->formatErrors( $this->preErrors ); + if ( $s ) { + $form->addHeaderText( Html::rawElement( + 'div', + [ 'class' => 'error' ], + $s + ) ); + } + } + } + + protected function getDisplayFormat() { + return 'ooui'; + } + + /** + * Get the HTMLForm descriptor array for the block form + * @return array + */ + protected function getFormFields() { + global $wgBlockAllowsUTEdit; + + $user = $this->getUser(); + + $suggestedDurations = self::getSuggestedDurations(); + + $conf = $this->getConfig(); + $oldCommentSchema = $conf->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD; + + $a = [ + 'Target' => [ + 'type' => 'user', + 'ipallowed' => true, + 'iprange' => true, + 'label-message' => 'ipaddressorusername', + 'id' => 'mw-bi-target', + 'size' => '45', + 'autofocus' => true, + 'required' => true, + 'validation-callback' => [ __CLASS__, 'validateTargetField' ], + ], + 'Expiry' => [ + 'type' => !count( $suggestedDurations ) ? 'text' : 'selectorother', + 'label-message' => 'ipbexpiry', + 'required' => true, + 'options' => $suggestedDurations, + 'other' => $this->msg( 'ipbother' )->text(), + 'default' => $this->msg( 'ipb-default-expiry' )->inContentLanguage()->text(), + ], + 'Reason' => [ + 'type' => 'selectandother', + // 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 (or 255 UTF-8 bytes for old schema). + 'maxlength' => $oldCommentSchema ? 255 : CommentStore::COMMENT_CHARACTER_LIMIT, + 'maxlength-unit' => 'codepoints', + 'label-message' => 'ipbreason', + 'options-message' => 'ipbreason-dropdown', + ], + 'CreateAccount' => [ + 'type' => 'check', + 'label-message' => 'ipbcreateaccount', + 'default' => true, + ], + ]; + + if ( self::canBlockEmail( $user ) ) { + $a['DisableEmail'] = [ + 'type' => 'check', + 'label-message' => 'ipbemailban', + ]; + } + + if ( $wgBlockAllowsUTEdit ) { + $a['DisableUTEdit'] = [ + 'type' => 'check', + 'label-message' => 'ipb-disableusertalk', + 'default' => false, + ]; + } + + $a['AutoBlock'] = [ + 'type' => 'check', + 'label-message' => 'ipbenableautoblock', + 'default' => true, + ]; + + # Allow some users to hide name from block log, blocklist and listusers + if ( $user->isAllowed( 'hideuser' ) ) { + $a['HideUser'] = [ + 'type' => 'check', + 'label-message' => 'ipbhidename', + 'cssclass' => 'mw-block-hideuser', + ]; + } + + # Watchlist their user page? (Only if user is logged in) + if ( $user->isLoggedIn() ) { + $a['Watch'] = [ + 'type' => 'check', + 'label-message' => 'ipbwatchuser', + ]; + } + + $a['HardBlock'] = [ + 'type' => 'check', + 'label-message' => 'ipb-hardblock', + 'default' => false, + ]; + + # This is basically a copy of the Target field, but the user can't change it, so we + # can see if the warnings we maybe showed to the user before still apply + $a['PreviousTarget'] = [ + 'type' => 'hidden', + 'default' => false, + ]; + + # We'll turn this into a checkbox if we need to + $a['Confirm'] = [ + 'type' => 'hidden', + 'default' => '', + 'label-message' => 'ipb-confirm', + 'cssclass' => 'mw-block-confirm', + ]; + + $this->maybeAlterFormDefaults( $a ); + + // Allow extensions to add more fields + Hooks::run( 'SpecialBlockModifyFormFields', [ $this, &$a ] ); + + return $a; + } + + /** + * If the user has already been blocked with similar settings, load that block + * and change the defaults for the form fields to match the existing settings. + * @param array &$fields HTMLForm descriptor array + * @return bool Whether fields were altered (that is, whether the target is + * already blocked) + */ + protected function maybeAlterFormDefaults( &$fields ) { + # This will be overwritten by request data + $fields['Target']['default'] = (string)$this->target; + + if ( $this->target ) { + $status = self::validateTarget( $this->target, $this->getUser() ); + if ( !$status->isOK() ) { + $errors = $status->getErrorsArray(); + $this->preErrors = array_merge( $this->preErrors, $errors ); + } + } + + # This won't be + $fields['PreviousTarget']['default'] = (string)$this->target; + + $block = Block::newFromTarget( $this->target ); + + if ( $block instanceof Block && !$block->mAuto # The block exists and isn't an autoblock + && ( $this->type != Block::TYPE_RANGE # The block isn't a rangeblock + || $block->getTarget() == $this->target ) # or if it is, the range is what we're about to block + ) { + $fields['HardBlock']['default'] = $block->isHardblock(); + $fields['CreateAccount']['default'] = $block->prevents( 'createaccount' ); + $fields['AutoBlock']['default'] = $block->isAutoblocking(); + + if ( isset( $fields['DisableEmail'] ) ) { + $fields['DisableEmail']['default'] = $block->prevents( 'sendemail' ); + } + + if ( isset( $fields['HideUser'] ) ) { + $fields['HideUser']['default'] = $block->mHideName; + } + + if ( isset( $fields['DisableUTEdit'] ) ) { + $fields['DisableUTEdit']['default'] = $block->prevents( 'editownusertalk' ); + } + + // If the username was hidden (ipb_deleted == 1), don't show the reason + // unless this user also has rights to hideuser: T37839 + if ( !$block->mHideName || $this->getUser()->isAllowed( 'hideuser' ) ) { + $fields['Reason']['default'] = $block->mReason; + } else { + $fields['Reason']['default'] = ''; + } + + if ( $this->getRequest()->wasPosted() ) { + # Ok, so we got a POST submission asking us to reblock a user. So show the + # confirm checkbox; the user will only see it if they haven't previously + $fields['Confirm']['type'] = 'check'; + } else { + # We got a target, but it wasn't a POST request, so the user must have gone + # to a link like [[Special:Block/User]]. We don't need to show the checkbox + # as long as they go ahead and block *that* user + $fields['Confirm']['default'] = 1; + } + + if ( $block->mExpiry == 'infinity' ) { + $fields['Expiry']['default'] = 'infinite'; + } else { + $fields['Expiry']['default'] = wfTimestamp( TS_RFC2822, $block->mExpiry ); + } + + $this->alreadyBlocked = true; + $this->preErrors[] = [ 'ipb-needreblock', wfEscapeWikiText( (string)$block->getTarget() ) ]; + } + + # We always need confirmation to do HideUser + if ( $this->requestedHideUser ) { + $fields['Confirm']['type'] = 'check'; + unset( $fields['Confirm']['default'] ); + $this->preErrors[] = [ 'ipb-confirmhideuser', 'ipb-confirmaction' ]; + } + + # Or if the user is trying to block themselves + if ( (string)$this->target === $this->getUser()->getName() ) { + $fields['Confirm']['type'] = 'check'; + unset( $fields['Confirm']['default'] ); + $this->preErrors[] = [ 'ipb-blockingself', 'ipb-confirmaction' ]; + } + } + + /** + * Add header elements like block log entries, etc. + * @return string + */ + protected function preText() { + $this->getOutput()->addModules( [ 'mediawiki.special.block' ] ); + + $blockCIDRLimit = $this->getConfig()->get( 'BlockCIDRLimit' ); + $text = $this->msg( 'blockiptext', $blockCIDRLimit['IPv4'], $blockCIDRLimit['IPv6'] )->parse(); + + $otherBlockMessages = []; + if ( $this->target !== null ) { + $targetName = $this->target; + if ( $this->target instanceof User ) { + $targetName = $this->target->getName(); + } + # Get other blocks, i.e. from GlobalBlocking or TorBlock extension + Hooks::run( 'OtherBlockLogLink', [ &$otherBlockMessages, $targetName ] ); + + if ( count( $otherBlockMessages ) ) { + $s = Html::rawElement( + 'h2', + [], + $this->msg( 'ipb-otherblocks-header', count( $otherBlockMessages ) )->parse() + ) . "\n"; + + $list = ''; + + foreach ( $otherBlockMessages as $link ) { + $list .= Html::rawElement( 'li', [], $link ) . "\n"; + } + + $s .= Html::rawElement( + 'ul', + [ 'class' => 'mw-blockip-alreadyblocked' ], + $list + ) . "\n"; + + $text .= $s; + } + } + + return $text; + } + + /** + * Add footer elements to the form + * @return string + */ + protected function postText() { + $links = []; + + $this->getOutput()->addModuleStyles( 'mediawiki.special' ); + + $linkRenderer = $this->getLinkRenderer(); + # Link to the user's contributions, if applicable + if ( $this->target instanceof User ) { + $contribsPage = SpecialPage::getTitleFor( 'Contributions', $this->target->getName() ); + $links[] = $linkRenderer->makeLink( + $contribsPage, + $this->msg( 'ipb-blocklist-contribs', $this->target->getName() )->text() + ); + } + + # Link to unblock the specified user, or to a blank unblock form + if ( $this->target instanceof User ) { + $message = $this->msg( + 'ipb-unblock-addr', + wfEscapeWikiText( $this->target->getName() ) + )->parse(); + $list = SpecialPage::getTitleFor( 'Unblock', $this->target->getName() ); + } else { + $message = $this->msg( 'ipb-unblock' )->parse(); + $list = SpecialPage::getTitleFor( 'Unblock' ); + } + $links[] = $linkRenderer->makeKnownLink( + $list, + new HtmlArmor( $message ) + ); + + # Link to the block list + $links[] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'BlockList' ), + $this->msg( 'ipb-blocklist' )->text() + ); + + $user = $this->getUser(); + + # Link to edit the block dropdown reasons, if applicable + if ( $user->isAllowed( 'editinterface' ) ) { + $links[] = $linkRenderer->makeKnownLink( + $this->msg( 'ipbreason-dropdown' )->inContentLanguage()->getTitle(), + $this->msg( 'ipb-edit-dropdown' )->text(), + [], + [ 'action' => 'edit' ] + ); + } + + $text = Html::rawElement( + 'p', + [ 'class' => 'mw-ipb-conveniencelinks' ], + $this->getLanguage()->pipeList( $links ) + ); + + $userTitle = self::getTargetUserTitle( $this->target ); + if ( $userTitle ) { + # Get relevant extracts from the block and suppression logs, if possible + $out = ''; + + LogEventsList::showLogExtract( + $out, + 'block', + $userTitle, + '', + [ + 'lim' => 10, + 'msgKey' => [ 'blocklog-showlog', $userTitle->getText() ], + 'showIfEmpty' => false + ] + ); + $text .= $out; + + # Add suppression block entries if allowed + if ( $user->isAllowed( 'suppressionlog' ) ) { + LogEventsList::showLogExtract( + $out, + 'suppress', + $userTitle, + '', + [ + 'lim' => 10, + 'conds' => [ 'log_action' => [ 'block', 'reblock', 'unblock' ] ], + 'msgKey' => [ 'blocklog-showsuppresslog', $userTitle->getText() ], + 'showIfEmpty' => false + ] + ); + + $text .= $out; + } + } + + return $text; + } + + /** + * Get a user page target for things like logs. + * This handles account and IP range targets. + * @param User|string $target + * @return Title|null + */ + protected static function getTargetUserTitle( $target ) { + if ( $target instanceof User ) { + return $target->getUserPage(); + } elseif ( IP::isIPAddress( $target ) ) { + return Title::makeTitleSafe( NS_USER, $target ); + } + + return null; + } + + /** + * Determine the target of the block, and the type of target + * @todo Should be in Block.php? + * @param string $par Subpage parameter passed to setup, or data value from + * the HTMLForm + * @param WebRequest $request Optionally try and get data from a request too + * @return array [ User|string|null, Block::TYPE_ constant|null ] + */ + public static function getTargetAndType( $par, WebRequest $request = null ) { + $i = 0; + $target = null; + + while ( true ) { + switch ( $i++ ) { + case 0: + # The HTMLForm will check wpTarget first and only if it doesn't get + # a value use the default, which will be generated from the options + # below; so this has to have a higher precedence here than $par, or + # we could end up with different values in $this->target and the HTMLForm! + if ( $request instanceof WebRequest ) { + $target = $request->getText( 'wpTarget', null ); + } + break; + case 1: + $target = $par; + break; + case 2: + if ( $request instanceof WebRequest ) { + $target = $request->getText( 'ip', null ); + } + break; + case 3: + # B/C @since 1.18 + if ( $request instanceof WebRequest ) { + $target = $request->getText( 'wpBlockAddress', null ); + } + break; + case 4: + break 2; + } + + list( $target, $type ) = Block::parseTarget( $target ); + + if ( $type !== null ) { + return [ $target, $type ]; + } + } + + return [ null, null ]; + } + + /** + * HTMLForm field validation-callback for Target field. + * @since 1.18 + * @param string $value + * @param array $alldata + * @param HTMLForm $form + * @return Message + */ + public static function validateTargetField( $value, $alldata, $form ) { + $status = self::validateTarget( $value, $form->getUser() ); + if ( !$status->isOK() ) { + $errors = $status->getErrorsArray(); + + return call_user_func_array( [ $form, 'msg' ], $errors[0] ); + } else { + return true; + } + } + + /** + * Validate a block target. + * + * @since 1.21 + * @param string $value Block target to check + * @param User $user Performer of the block + * @return Status + */ + public static function validateTarget( $value, User $user ) { + global $wgBlockCIDRLimit; + + /** @var User $target */ + list( $target, $type ) = self::getTargetAndType( $value ); + $status = Status::newGood( $target ); + + if ( $type == Block::TYPE_USER ) { + if ( $target->isAnon() ) { + $status->fatal( + 'nosuchusershort', + wfEscapeWikiText( $target->getName() ) + ); + } + + $unblockStatus = self::checkUnblockSelf( $target, $user ); + if ( $unblockStatus !== true ) { + $status->fatal( 'badaccess', $unblockStatus ); + } + } elseif ( $type == Block::TYPE_RANGE ) { + list( $ip, $range ) = explode( '/', $target, 2 ); + + if ( + ( IP::isIPv4( $ip ) && $wgBlockCIDRLimit['IPv4'] == 32 ) || + ( IP::isIPv6( $ip ) && $wgBlockCIDRLimit['IPv6'] == 128 ) + ) { + // Range block effectively disabled + $status->fatal( 'range_block_disabled' ); + } + + if ( + ( IP::isIPv4( $ip ) && $range > 32 ) || + ( IP::isIPv6( $ip ) && $range > 128 ) + ) { + // Dodgy range + $status->fatal( 'ip_range_invalid' ); + } + + if ( IP::isIPv4( $ip ) && $range < $wgBlockCIDRLimit['IPv4'] ) { + $status->fatal( 'ip_range_toolarge', $wgBlockCIDRLimit['IPv4'] ); + } + + if ( IP::isIPv6( $ip ) && $range < $wgBlockCIDRLimit['IPv6'] ) { + $status->fatal( 'ip_range_toolarge', $wgBlockCIDRLimit['IPv6'] ); + } + } elseif ( $type == Block::TYPE_IP ) { + # All is well + } else { + $status->fatal( 'badipaddress' ); + } + + return $status; + } + + /** + * Given the form data, actually implement a block. This is also called from ApiBlock. + * + * @param array $data + * @param IContextSource $context + * @return bool|string + */ + public static function processForm( array $data, IContextSource $context ) { + global $wgBlockAllowsUTEdit, $wgHideUserContribLimit; + + $performer = $context->getUser(); + + // Handled by field validator callback + // self::validateTargetField( $data['Target'] ); + + # This might have been a hidden field or a checkbox, so interesting data + # can come from it + $data['Confirm'] = !in_array( $data['Confirm'], [ '', '0', null, false ], true ); + + /** @var User $target */ + list( $target, $type ) = self::getTargetAndType( $data['Target'] ); + if ( $type == Block::TYPE_USER ) { + $user = $target; + $target = $user->getName(); + $userId = $user->getId(); + + # Give admins a heads-up before they go and block themselves. Much messier + # to do this for IPs, but it's pretty unlikely they'd ever get the 'block' + # permission anyway, although the code does allow for it. + # Note: Important to use $target instead of $data['Target'] + # since both $data['PreviousTarget'] and $target are normalized + # but $data['target'] gets overridden by (non-normalized) request variable + # from previous request. + if ( $target === $performer->getName() && + ( $data['PreviousTarget'] !== $target || !$data['Confirm'] ) + ) { + return [ 'ipb-blockingself', 'ipb-confirmaction' ]; + } + } elseif ( $type == Block::TYPE_RANGE ) { + $user = null; + $userId = 0; + } elseif ( $type == Block::TYPE_IP ) { + $user = null; + $target = $target->getName(); + $userId = 0; + } else { + # This should have been caught in the form field validation + return [ 'badipaddress' ]; + } + + $expiryTime = self::parseExpiryInput( $data['Expiry'] ); + + if ( + // an expiry time is needed + ( strlen( $data['Expiry'] ) == 0 ) || + // can't be a larger string as 50 (it should be a time format in any way) + ( strlen( $data['Expiry'] ) > 50 ) || + // check, if the time could be parsed + !$expiryTime + ) { + return [ 'ipb_expiry_invalid' ]; + } + + // an expiry time should be in the future, not in the + // past (wouldn't make any sense) - bug T123069 + if ( $expiryTime < wfTimestampNow() ) { + return [ 'ipb_expiry_old' ]; + } + + if ( !isset( $data['DisableEmail'] ) ) { + $data['DisableEmail'] = false; + } + + # If the user has done the form 'properly', they won't even have been given the + # option to suppress-block unless they have the 'hideuser' permission + if ( !isset( $data['HideUser'] ) ) { + $data['HideUser'] = false; + } + + if ( $data['HideUser'] ) { + if ( !$performer->isAllowed( 'hideuser' ) ) { + # this codepath is unreachable except by a malicious user spoofing forms, + # or by race conditions (user has hideuser and block rights, loads block form, + # and loses hideuser rights before submission); so need to fail completely + # rather than just silently disable hiding + return [ 'badaccess-group0' ]; + } + + # Recheck params here... + if ( $type != Block::TYPE_USER ) { + $data['HideUser'] = false; # IP users should not be hidden + } elseif ( !wfIsInfinity( $data['Expiry'] ) ) { + # Bad expiry. + return [ 'ipb_expiry_temp' ]; + } elseif ( $wgHideUserContribLimit !== false + && $user->getEditCount() > $wgHideUserContribLimit + ) { + # Typically, the user should have a handful of edits. + # Disallow hiding users with many edits for performance. + return [ [ 'ipb_hide_invalid', + Message::numParam( $wgHideUserContribLimit ) ] ]; + } elseif ( !$data['Confirm'] ) { + return [ 'ipb-confirmhideuser', 'ipb-confirmaction' ]; + } + } + + # Create block object. + $block = new Block(); + $block->setTarget( $target ); + $block->setBlocker( $performer ); + $block->mReason = $data['Reason'][0]; + $block->mExpiry = $expiryTime; + $block->prevents( 'createaccount', $data['CreateAccount'] ); + $block->prevents( 'editownusertalk', ( !$wgBlockAllowsUTEdit || $data['DisableUTEdit'] ) ); + $block->prevents( 'sendemail', $data['DisableEmail'] ); + $block->isHardblock( $data['HardBlock'] ); + $block->isAutoblocking( $data['AutoBlock'] ); + $block->mHideName = $data['HideUser']; + + $reason = [ 'hookaborted' ]; + if ( !Hooks::run( 'BlockIp', [ &$block, &$performer, &$reason ] ) ) { + return $reason; + } + + $priorBlock = null; + # Try to insert block. Is there a conflicting block? + $status = $block->insert(); + if ( !$status ) { + # Indicates whether the user is confirming the block and is aware of + # the conflict (did not change the block target in the meantime) + $blockNotConfirmed = !$data['Confirm'] || ( array_key_exists( 'PreviousTarget', $data ) + && $data['PreviousTarget'] !== $target ); + + # Special case for API - T34434 + $reblockNotAllowed = ( array_key_exists( 'Reblock', $data ) && !$data['Reblock'] ); + + # Show form unless the user is already aware of this... + if ( $blockNotConfirmed || $reblockNotAllowed ) { + return [ [ 'ipb_already_blocked', $block->getTarget() ] ]; + # Otherwise, try to update the block... + } else { + # This returns direct blocks before autoblocks/rangeblocks, since we should + # be sure the user is blocked by now it should work for our purposes + $currentBlock = Block::newFromTarget( $target ); + if ( $block->equals( $currentBlock ) ) { + return [ [ 'ipb_already_blocked', $block->getTarget() ] ]; + } + # If the name was hidden and the blocking user cannot hide + # names, then don't allow any block changes... + if ( $currentBlock->mHideName && !$performer->isAllowed( 'hideuser' ) ) { + return [ 'cant-see-hidden-user' ]; + } + + $priorBlock = clone $currentBlock; + $currentBlock->isHardblock( $block->isHardblock() ); + $currentBlock->prevents( 'createaccount', $block->prevents( 'createaccount' ) ); + $currentBlock->mExpiry = $block->mExpiry; + $currentBlock->isAutoblocking( $block->isAutoblocking() ); + $currentBlock->mHideName = $block->mHideName; + $currentBlock->prevents( 'sendemail', $block->prevents( 'sendemail' ) ); + $currentBlock->prevents( 'editownusertalk', $block->prevents( 'editownusertalk' ) ); + $currentBlock->mReason = $block->mReason; + + $status = $currentBlock->update(); + + $logaction = 'reblock'; + + # Unset _deleted fields if requested + if ( $currentBlock->mHideName && !$data['HideUser'] ) { + RevisionDeleteUser::unsuppressUserName( $target, $userId ); + } + + # If hiding/unhiding a name, this should go in the private logs + if ( (bool)$currentBlock->mHideName ) { + $data['HideUser'] = true; + } + } + } else { + $logaction = 'block'; + } + + Hooks::run( 'BlockIpComplete', [ $block, $performer, $priorBlock ] ); + + # Set *_deleted fields if requested + if ( $data['HideUser'] ) { + RevisionDeleteUser::suppressUserName( $target, $userId ); + } + + # Can't watch a rangeblock + if ( $type != Block::TYPE_RANGE && $data['Watch'] ) { + WatchAction::doWatch( + Title::makeTitle( NS_USER, $target ), + $performer, + User::IGNORE_USER_RIGHTS + ); + } + + # Block constructor sanitizes certain block options on insert + $data['BlockEmail'] = $block->prevents( 'sendemail' ); + $data['AutoBlock'] = $block->isAutoblocking(); + + # Prepare log parameters + $logParams = []; + $logParams['5::duration'] = $data['Expiry']; + $logParams['6::flags'] = self::blockLogFlags( $data, $type ); + + # Make log entry, if the name is hidden, put it in the suppression log + $log_type = $data['HideUser'] ? 'suppress' : 'block'; + $logEntry = new ManualLogEntry( $log_type, $logaction ); + $logEntry->setTarget( Title::makeTitle( NS_USER, $target ) ); + $logEntry->setComment( $data['Reason'][0] ); + $logEntry->setPerformer( $performer ); + $logEntry->setParameters( $logParams ); + # Relate log ID to block IDs (T27763) + $blockIds = array_merge( [ $status['id'] ], $status['autoIds'] ); + $logEntry->setRelations( [ 'ipb_id' => $blockIds ] ); + $logId = $logEntry->insert(); + + if ( !empty( $data['Tags'] ) ) { + $logEntry->setTags( $data['Tags'] ); + } + + $logEntry->publish( $logId ); + + return true; + } + + /** + * Get an array of suggested block durations from MediaWiki:Ipboptions + * @todo FIXME: This uses a rather odd syntax for the options, should it be converted + * to the standard "**<duration>|<displayname>" format? + * @param Language|null $lang The language to get the durations in, or null to use + * the wiki's content language + * @return array + */ + public static function getSuggestedDurations( $lang = null ) { + $a = []; + $msg = $lang === null + ? wfMessage( 'ipboptions' )->inContentLanguage()->text() + : wfMessage( 'ipboptions' )->inLanguage( $lang )->text(); + + if ( $msg == '-' ) { + return []; + } + + foreach ( explode( ',', $msg ) as $option ) { + if ( strpos( $option, ':' ) === false ) { + $option = "$option:$option"; + } + + list( $show, $value ) = explode( ':', $option ); + $a[$show] = $value; + } + + return $a; + } + + /** + * Convert a submitted expiry time, which may be relative ("2 weeks", etc) or absolute + * ("24 May 2034", etc), into an absolute timestamp we can put into the database. + * @param string $expiry Whatever was typed into the form + * @return string Timestamp or 'infinity' + */ + public static function parseExpiryInput( $expiry ) { + if ( wfIsInfinity( $expiry ) ) { + $expiry = 'infinity'; + } else { + $expiry = strtotime( $expiry ); + + if ( $expiry < 0 || $expiry === false ) { + return false; + } + + $expiry = wfTimestamp( TS_MW, $expiry ); + } + + return $expiry; + } + + /** + * Can we do an email block? + * @param User $user The sysop wanting to make a block + * @return bool + */ + public static function canBlockEmail( $user ) { + global $wgEnableUserEmail, $wgSysopEmailBans; + + return ( $wgEnableUserEmail && $wgSysopEmailBans && $user->isAllowed( 'blockemail' ) ); + } + + /** + * T17810: blocked admins should not be able to block/unblock + * others, and probably shouldn't be able to unblock themselves + * either. + * @param User|int|string $user + * @param User $performer User doing the request + * @return bool|string True or error message key + */ + public static function checkUnblockSelf( $user, User $performer ) { + if ( is_int( $user ) ) { + $user = User::newFromId( $user ); + } elseif ( is_string( $user ) ) { + $user = User::newFromName( $user ); + } + + if ( $performer->isBlocked() ) { + if ( $user instanceof User && $user->getId() == $performer->getId() ) { + # User is trying to unblock themselves + if ( $performer->isAllowed( 'unblockself' ) ) { + return true; + # User blocked themselves and is now trying to reverse it + } elseif ( $performer->blockedBy() === $performer->getName() ) { + return true; + } else { + return 'ipbnounblockself'; + } + } else { + # User is trying to block/unblock someone else + return 'ipbblocked'; + } + } else { + return true; + } + } + + /** + * Return a comma-delimited list of "flags" to be passed to the log + * reader for this block, to provide more information in the logs + * @param array $data From HTMLForm data + * @param int $type Block::TYPE_ constant (USER, RANGE, or IP) + * @return string + */ + protected static function blockLogFlags( array $data, $type ) { + global $wgBlockAllowsUTEdit; + $flags = []; + + # when blocking a user the option 'anononly' is not available/has no effect + # -> do not write this into log + if ( !$data['HardBlock'] && $type != Block::TYPE_USER ) { + // For grepping: message block-log-flags-anononly + $flags[] = 'anononly'; + } + + if ( $data['CreateAccount'] ) { + // For grepping: message block-log-flags-nocreate + $flags[] = 'nocreate'; + } + + # Same as anononly, this is not displayed when blocking an IP address + if ( !$data['AutoBlock'] && $type == Block::TYPE_USER ) { + // For grepping: message block-log-flags-noautoblock + $flags[] = 'noautoblock'; + } + + if ( $data['DisableEmail'] ) { + // For grepping: message block-log-flags-noemail + $flags[] = 'noemail'; + } + + if ( $wgBlockAllowsUTEdit && $data['DisableUTEdit'] ) { + // For grepping: message block-log-flags-nousertalk + $flags[] = 'nousertalk'; + } + + if ( $data['HideUser'] ) { + // For grepping: message block-log-flags-hiddenname + $flags[] = 'hiddenname'; + } + + return implode( ',', $flags ); + } + + /** + * Process the form on POST submission. + * @param array $data + * @param HTMLForm $form + * @return bool|array True for success, false for didn't-try, array of errors on failure + */ + public function onSubmit( array $data, HTMLForm $form = null ) { + return self::processForm( $data, $form->getContext() ); + } + + /** + * Do something exciting on successful processing of the form, most likely to show a + * confirmation message + */ + public function onSuccess() { + $out = $this->getOutput(); + $out->setPageTitle( $this->msg( 'blockipsuccesssub' ) ); + $out->addWikiMsg( 'blockipsuccesstext', wfEscapeWikiText( $this->target ) ); + } + + /** + * 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'; + } +} diff --git a/www/wiki/includes/specials/SpecialBlockList.php b/www/wiki/includes/specials/SpecialBlockList.php new file mode 100644 index 00000000..0899d580 --- /dev/null +++ b/www/wiki/includes/specials/SpecialBlockList.php @@ -0,0 +1,225 @@ +<?php +/** + * Implements Special:BlockList + * + * 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 + */ + +/** + * A special page that lists existing blocks + * + * @ingroup SpecialPage + */ +class SpecialBlockList extends SpecialPage { + protected $target; + + protected $options; + + function __construct() { + parent::__construct( 'BlockList' ); + } + + /** + * Main execution point + * + * @param string $par Title fragment + */ + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + $out = $this->getOutput(); + $lang = $this->getLanguage(); + $out->setPageTitle( $this->msg( 'ipblocklist' ) ); + $out->addModuleStyles( [ 'mediawiki.special' ] ); + + $request = $this->getRequest(); + $par = $request->getVal( 'ip', $par ); + $this->target = trim( $request->getVal( 'wpTarget', $par ) ); + + $this->options = $request->getArray( 'wpOptions', [] ); + + $action = $request->getText( 'action' ); + + if ( $action == 'unblock' || $action == 'submit' && $request->wasPosted() ) { + # B/C @since 1.18: Unblock interface is now at Special:Unblock + $title = SpecialPage::getTitleFor( 'Unblock', $this->target ); + $out->redirect( $title->getFullURL() ); + + return; + } + + # setup BlockListPager here to get the actual default Limit + $pager = $this->getBlockListPager(); + + # Just show the block list + $fields = [ + 'Target' => [ + 'type' => 'user', + 'label-message' => 'ipaddressorusername', + 'tabindex' => '1', + 'size' => '45', + 'default' => $this->target, + ], + 'Options' => [ + 'type' => 'multiselect', + 'options-messages' => [ + 'blocklist-userblocks' => 'userblocks', + 'blocklist-tempblocks' => 'tempblocks', + 'blocklist-addressblocks' => 'addressblocks', + 'blocklist-rangeblocks' => 'rangeblocks', + ], + 'flatlist' => true, + ], + 'Limit' => [ + 'type' => 'limitselect', + 'label-message' => 'table_pager_limit_label', + 'options' => [ + $lang->formatNum( 20 ) => 20, + $lang->formatNum( 50 ) => 50, + $lang->formatNum( 100 ) => 100, + $lang->formatNum( 250 ) => 250, + $lang->formatNum( 500 ) => 500, + ], + 'name' => 'limit', + 'default' => $pager->getLimit(), + ], + ]; + $context = new DerivativeContext( $this->getContext() ); + $context->setTitle( $this->getPageTitle() ); // Remove subpage + $form = HTMLForm::factory( 'ooui', $fields, $context ); + $form + ->setMethod( 'get' ) + ->setFormIdentifier( 'blocklist' ) + ->setWrapperLegendMsg( 'ipblocklist-legend' ) + ->setSubmitTextMsg( 'ipblocklist-submit' ) + ->setSubmitProgressive() + ->prepareForm() + ->displayForm( false ); + + $this->showList( $pager ); + } + + /** + * Setup a new BlockListPager instance. + * @return BlockListPager + */ + protected function getBlockListPager() { + $conds = []; + # Is the user allowed to see hidden blocks? + if ( !$this->getUser()->isAllowed( 'hideuser' ) ) { + $conds['ipb_deleted'] = 0; + } + + if ( $this->target !== '' ) { + list( $target, $type ) = Block::parseTarget( $this->target ); + + switch ( $type ) { + case Block::TYPE_ID: + case Block::TYPE_AUTO: + $conds['ipb_id'] = $target; + break; + + case Block::TYPE_IP: + case Block::TYPE_RANGE: + list( $start, $end ) = IP::parseRange( $target ); + $conds[] = wfGetDB( DB_REPLICA )->makeList( + [ + 'ipb_address' => $target, + Block::getRangeCond( $start, $end ) + ], + LIST_OR + ); + $conds['ipb_auto'] = 0; + break; + + case Block::TYPE_USER: + $conds['ipb_address'] = $target->getName(); + $conds['ipb_auto'] = 0; + break; + } + } + + # Apply filters + if ( in_array( 'userblocks', $this->options ) ) { + $conds['ipb_user'] = 0; + } + if ( in_array( 'tempblocks', $this->options ) ) { + $conds['ipb_expiry'] = 'infinity'; + } + if ( in_array( 'addressblocks', $this->options ) ) { + $conds[] = "ipb_user != 0 OR ipb_range_end > ipb_range_start"; + } + if ( in_array( 'rangeblocks', $this->options ) ) { + $conds[] = "ipb_range_end = ipb_range_start"; + } + + return new BlockListPager( $this, $conds ); + } + + /** + * Show the list of blocked accounts matching the actual filter. + * @param BlockListPager $pager The BlockListPager instance for this page + */ + protected function showList( BlockListPager $pager ) { + $out = $this->getOutput(); + + # Check for other blocks, i.e. global/tor blocks + $otherBlockLink = []; + Hooks::run( 'OtherBlockLogLink', [ &$otherBlockLink, $this->target ] ); + + # Show additional header for the local block only when other blocks exists. + # Not necessary in a standard installation without such extensions enabled + if ( count( $otherBlockLink ) ) { + $out->addHTML( + Html::element( 'h2', [], $this->msg( 'ipblocklist-localblock' )->text() ) . "\n" + ); + } + + if ( $pager->getNumRows() ) { + $out->addParserOutputContent( $pager->getFullOutput() ); + } elseif ( $this->target ) { + $out->addWikiMsg( 'ipblocklist-no-results' ); + } else { + $out->addWikiMsg( 'ipblocklist-empty' ); + } + + if ( count( $otherBlockLink ) ) { + $out->addHTML( + Html::rawElement( + 'h2', + [], + $this->msg( 'ipblocklist-otherblocks', count( $otherBlockLink ) )->parse() + ) . "\n" + ); + $list = ''; + foreach ( $otherBlockLink as $link ) { + $list .= Html::rawElement( 'li', [], $link ) . "\n"; + } + $out->addHTML( Html::rawElement( + 'ul', + [ 'class' => 'mw-ipblocklist-otherblocks' ], + $list + ) . "\n" ); + } + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/www/wiki/includes/specials/SpecialBooksources.php b/www/wiki/includes/specials/SpecialBooksources.php new file mode 100644 index 00000000..72e0b888 --- /dev/null +++ b/www/wiki/includes/specials/SpecialBooksources.php @@ -0,0 +1,214 @@ +<?php +/** + * Implements Special:Booksources + * + * 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 + */ + +/** + * Special page outputs information on sourcing a book with a particular ISBN + * The parser creates links to this page when dealing with ISBNs in wikitext + * + * @author Rob Church <robchur@gmail.com> + * @ingroup SpecialPage + */ +class SpecialBookSources extends SpecialPage { + public function __construct() { + parent::__construct( 'Booksources' ); + } + + /** + * Show the special page + * + * @param string $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( + "<div class=\"error\">\n$1\n</div>", + '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(); + + global $wgContLang; + + $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->getNativeData(); + $out->addWikiText( 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( '<ul>' ); + $items = $wgContLang->getBookstoreList(); + foreach ( $items as $label => $url ) { + $out->addHTML( $this->makeListItem( $isbn, $label, $url ) ); + } + $out->addHTML( '</ul>' ); + + 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/www/wiki/includes/specials/SpecialBotPasswords.php b/www/wiki/includes/specials/SpecialBotPasswords.php new file mode 100644 index 00000000..961ee1c5 --- /dev/null +++ b/www/wiki/includes/specials/SpecialBotPasswords.php @@ -0,0 +1,367 @@ +<?php +/** + * Implements Special:BotPasswords + * + * 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 + */ + +/** + * Let users manage bot passwords + * + * @ingroup SpecialPage + */ +class SpecialBotPasswords extends FormSpecialPage { + + /** @var int Central user ID */ + private $userId = 0; + + /** @var BotPassword|null Bot password being edited, if any */ + private $botPassword = null; + + /** @var string Operation being performed: create, update, delete */ + private $operation = null; + + /** @var string New password set, for communication between onSubmit() and onSuccess() */ + private $password = null; + + public function __construct() { + parent::__construct( 'BotPasswords', 'editmyprivateinfo' ); + } + + /** + * @return bool + */ + public function isListed() { + return $this->getConfig()->get( 'EnableBotPasswords' ); + } + + protected function getLoginSecurityLevel() { + return $this->getName(); + } + + /** + * Main execution point + * @param string|null $par + */ + function execute( $par ) { + $this->getOutput()->disallowUserJs(); + $this->requireLogin(); + + $par = trim( $par ); + if ( strlen( $par ) === 0 ) { + $par = null; + } elseif ( strlen( $par ) > BotPassword::APPID_MAXLENGTH ) { + throw new ErrorPageError( 'botpasswords', 'botpasswords-bad-appid', + [ htmlspecialchars( $par ) ] ); + } + + parent::execute( $par ); + } + + protected function checkExecutePermissions( User $user ) { + parent::checkExecutePermissions( $user ); + + if ( !$this->getConfig()->get( 'EnableBotPasswords' ) ) { + throw new ErrorPageError( 'botpasswords', 'botpasswords-disabled' ); + } + + $this->userId = CentralIdLookup::factory()->centralIdFromLocalUser( $this->getUser() ); + if ( !$this->userId ) { + throw new ErrorPageError( 'botpasswords', 'botpasswords-no-central-id' ); + } + } + + protected function getFormFields() { + $fields = []; + + if ( $this->par !== null ) { + $this->botPassword = BotPassword::newFromCentralId( $this->userId, $this->par ); + if ( !$this->botPassword ) { + $this->botPassword = BotPassword::newUnsaved( [ + 'centralId' => $this->userId, + 'appId' => $this->par, + ] ); + } + + $sep = BotPassword::getSeparator(); + $fields[] = [ + 'type' => 'info', + 'label-message' => 'username', + 'default' => $this->getUser()->getName() . $sep . $this->par + ]; + + if ( $this->botPassword->isSaved() ) { + $fields['resetPassword'] = [ + 'type' => 'check', + 'label-message' => 'botpasswords-label-resetpassword', + ]; + if ( $this->botPassword->isInvalid() ) { + $fields['resetPassword']['default'] = true; + } + } + + $lang = $this->getLanguage(); + $showGrants = MWGrants::getValidGrants(); + $fields['grants'] = [ + 'type' => 'checkmatrix', + 'label-message' => 'botpasswords-label-grants', + 'help-message' => 'botpasswords-help-grants', + 'columns' => [ + $this->msg( 'botpasswords-label-grants-column' )->escaped() => 'grant' + ], + 'rows' => array_combine( + array_map( 'MWGrants::getGrantsLink', $showGrants ), + $showGrants + ), + 'default' => array_map( + function ( $g ) { + return "grant-$g"; + }, + $this->botPassword->getGrants() + ), + 'tooltips' => array_combine( + array_map( 'MWGrants::getGrantsLink', $showGrants ), + array_map( + function ( $rights ) use ( $lang ) { + return $lang->semicolonList( array_map( 'User::getRightDescription', $rights ) ); + }, + array_intersect_key( MWGrants::getRightsByGrant(), array_flip( $showGrants ) ) + ) + ), + 'force-options-on' => array_map( + function ( $g ) { + return "grant-$g"; + }, + MWGrants::getHiddenGrants() + ), + ]; + + $fields['restrictions'] = [ + 'class' => HTMLRestrictionsField::class, + 'required' => true, + 'default' => $this->botPassword->getRestrictions(), + ]; + + } else { + $linkRenderer = $this->getLinkRenderer(); + $passwordFactory = new PasswordFactory(); + $passwordFactory->init( $this->getConfig() ); + + $dbr = BotPassword::getDB( DB_REPLICA ); + $res = $dbr->select( + 'bot_passwords', + [ 'bp_app_id', 'bp_password' ], + [ 'bp_user' => $this->userId ], + __METHOD__ + ); + foreach ( $res as $row ) { + try { + $password = $passwordFactory->newFromCiphertext( $row->bp_password ); + $passwordInvalid = $password instanceof InvalidPassword; + unset( $password ); + } catch ( PasswordError $ex ) { + $passwordInvalid = true; + } + + $text = $linkRenderer->makeKnownLink( + $this->getPageTitle( $row->bp_app_id ), + $row->bp_app_id + ); + if ( $passwordInvalid ) { + $text .= $this->msg( 'word-separator' )->escaped() + . $this->msg( 'botpasswords-label-needsreset' )->parse(); + } + + $fields[] = [ + 'section' => 'existing', + 'type' => 'info', + 'raw' => true, + 'default' => $text, + ]; + } + + $fields['appId'] = [ + 'section' => 'createnew', + 'type' => 'textwithbutton', + 'label-message' => 'botpasswords-label-appid', + 'buttondefault' => $this->msg( 'botpasswords-label-create' )->text(), + 'buttonflags' => [ 'progressive', 'primary' ], + 'required' => true, + 'size' => BotPassword::APPID_MAXLENGTH, + 'maxlength' => BotPassword::APPID_MAXLENGTH, + 'validation-callback' => function ( $v ) { + $v = trim( $v ); + return $v !== '' && strlen( $v ) <= BotPassword::APPID_MAXLENGTH; + }, + ]; + + $fields[] = [ + 'type' => 'hidden', + 'default' => 'new', + 'name' => 'op', + ]; + } + + return $fields; + } + + protected function alterForm( HTMLForm $form ) { + $form->setId( 'mw-botpasswords-form' ); + $form->setTableId( 'mw-botpasswords-table' ); + $form->addPreText( $this->msg( 'botpasswords-summary' )->parseAsBlock() ); + $form->suppressDefaultSubmit(); + + if ( $this->par !== null ) { + if ( $this->botPassword->isSaved() ) { + $form->setWrapperLegendMsg( 'botpasswords-editexisting' ); + $form->addButton( [ + 'name' => 'op', + 'value' => 'update', + 'label-message' => 'botpasswords-label-update', + 'flags' => [ 'primary', 'progressive' ], + ] ); + $form->addButton( [ + 'name' => 'op', + 'value' => 'delete', + 'label-message' => 'botpasswords-label-delete', + 'flags' => [ 'destructive' ], + ] ); + } else { + $form->setWrapperLegendMsg( 'botpasswords-createnew' ); + $form->addButton( [ + 'name' => 'op', + 'value' => 'create', + 'label-message' => 'botpasswords-label-create', + 'flags' => [ 'primary', 'progressive' ], + ] ); + } + + $form->addButton( [ + 'name' => 'op', + 'value' => 'cancel', + 'label-message' => 'botpasswords-label-cancel' + ] ); + } + } + + public function onSubmit( array $data ) { + $op = $this->getRequest()->getVal( 'op', '' ); + + switch ( $op ) { + case 'new': + $this->getOutput()->redirect( $this->getPageTitle( $data['appId'] )->getFullURL() ); + return false; + + case 'create': + $this->operation = 'insert'; + return $this->save( $data ); + + case 'update': + $this->operation = 'update'; + return $this->save( $data ); + + case 'delete': + $this->operation = 'delete'; + $bp = BotPassword::newFromCentralId( $this->userId, $this->par ); + if ( $bp ) { + $bp->delete(); + } + return Status::newGood(); + + case 'cancel': + $this->getOutput()->redirect( $this->getPageTitle()->getFullURL() ); + return false; + } + + return false; + } + + private function save( array $data ) { + $bp = BotPassword::newUnsaved( [ + 'centralId' => $this->userId, + 'appId' => $this->par, + 'restrictions' => $data['restrictions'], + 'grants' => array_merge( + MWGrants::getHiddenGrants(), + preg_replace( '/^grant-/', '', $data['grants'] ) + ) + ] ); + + if ( $this->operation === 'insert' || !empty( $data['resetPassword'] ) ) { + $this->password = BotPassword::generatePassword( $this->getConfig() ); + $passwordFactory = new PasswordFactory(); + $passwordFactory->init( RequestContext::getMain()->getConfig() ); + $password = $passwordFactory->newFromPlaintext( $this->password ); + } else { + $password = null; + } + + if ( $bp->save( $this->operation, $password ) ) { + return Status::newGood(); + } else { + // Messages: botpasswords-insert-failed, botpasswords-update-failed + return Status::newFatal( "botpasswords-{$this->operation}-failed", $this->par ); + } + } + + public function onSuccess() { + $out = $this->getOutput(); + + $username = $this->getUser()->getName(); + switch ( $this->operation ) { + case 'insert': + $out->setPageTitle( $this->msg( 'botpasswords-created-title' )->text() ); + $out->addWikiMsg( 'botpasswords-created-body', $this->par, $username ); + break; + + case 'update': + $out->setPageTitle( $this->msg( 'botpasswords-updated-title' )->text() ); + $out->addWikiMsg( 'botpasswords-updated-body', $this->par, $username ); + break; + + case 'delete': + $out->setPageTitle( $this->msg( 'botpasswords-deleted-title' )->text() ); + $out->addWikiMsg( 'botpasswords-deleted-body', $this->par, $username ); + $this->password = null; + break; + } + + if ( $this->password !== null ) { + $sep = BotPassword::getSeparator(); + $out->addWikiMsg( + 'botpasswords-newpassword', + htmlspecialchars( $username . $sep . $this->par ), + htmlspecialchars( $this->password ), + htmlspecialchars( $username ), + htmlspecialchars( $this->par . $sep . $this->password ) + ); + $this->password = null; + } + + $out->addReturnTo( $this->getPageTitle() ); + } + + protected function getGroupName() { + return 'users'; + } + + protected function getDisplayFormat() { + return 'ooui'; + } +} diff --git a/www/wiki/includes/specials/SpecialBrokenRedirects.php b/www/wiki/includes/specials/SpecialBrokenRedirects.php new file mode 100644 index 00000000..3e1909b8 --- /dev/null +++ b/www/wiki/includes/specials/SpecialBrokenRedirects.php @@ -0,0 +1,179 @@ +<?php +/** + * Implements Special:Brokenredirects + * + * 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 + */ + +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * A special page listing redirects to non existent page. Those should be + * fixed to point to an existing page. + * + * @ingroup SpecialPage + */ +class BrokenRedirectsPage extends QueryPage { + function __construct( $name = 'BrokenRedirects' ) { + parent::__construct( $name ); + } + + public function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + function sortDescending() { + return false; + } + + function getPageHeader() { + return $this->msg( 'brokenredirectstext' )->parseAsBlock(); + } + + public function getQueryInfo() { + $dbr = wfGetDB( DB_REPLICA ); + + return [ + 'tables' => [ + 'redirect', + 'p1' => 'page', + 'p2' => 'page', + ], + 'fields' => [ + 'namespace' => 'p1.page_namespace', + 'title' => 'p1.page_title', + 'value' => 'p1.page_title', + 'rd_namespace', + 'rd_title', + 'rd_fragment', + ], + 'conds' => [ + // Exclude pages that don't exist locally as wiki pages, + // but aren't "broken" either. + // Special pages and interwiki links + 'rd_namespace >= 0', + 'rd_interwiki IS NULL OR rd_interwiki = ' . $dbr->addQuotes( '' ), + 'p2.page_namespace IS NULL', + ], + 'join_conds' => [ + 'p1' => [ 'JOIN', [ + 'rd_from=p1.page_id', + ] ], + 'p2' => [ 'LEFT JOIN', [ + 'rd_namespace=p2.page_namespace', + 'rd_title=p2.page_title' + ] ], + ], + ]; + } + + /** + * @return array + */ + function getOrderFields() { + return [ 'rd_namespace', 'rd_title', 'rd_from' ]; + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + $fromObj = Title::makeTitle( $result->namespace, $result->title ); + if ( isset( $result->rd_title ) ) { + $toObj = Title::makeTitle( $result->rd_namespace, $result->rd_title, $result->rd_fragment ); + } else { + $blinks = $fromObj->getBrokenLinksFrom(); # TODO: check for redirect, not for links + if ( $blinks ) { + $toObj = $blinks[0]; + } else { + $toObj = false; + } + } + + $linkRenderer = $this->getLinkRenderer(); + // $toObj may very easily be false if the $result list is cached + if ( !is_object( $toObj ) ) { + return '<del>' . $linkRenderer->makeLink( $fromObj ) . '</del>'; + } + + $from = $linkRenderer->makeKnownLink( + $fromObj, + null, + [], + [ 'redirect' => 'no' ] + ); + $links = []; + // 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( $fromObj )->supportsDirectEditing() + ) { + $links[] = $linkRenderer->makeKnownLink( + $fromObj, + $this->msg( 'brokenredirects-edit' )->text(), + [], + [ 'action' => 'edit' ] + ); + } + $to = $linkRenderer->makeBrokenLink( $toObj, $toObj->getFullText() ); + $arr = $this->getLanguage()->getArrow(); + + $out = $from . $this->msg( 'word-separator' )->escaped(); + + if ( $this->getUser()->isAllowed( 'delete' ) ) { + $links[] = $linkRenderer->makeKnownLink( + $fromObj, + $this->msg( 'brokenredirects-delete' )->text(), + [], + [ 'action' => 'delete' ] + ); + } + + if ( $links ) { + $out .= $this->msg( 'parentheses' )->rawParams( $this->getLanguage() + ->pipeList( $links ) )->escaped(); + } + $out .= " {$arr} {$to}"; + + return $out; + } + + /** + * Cache page content model for performance + * + * @param IDatabase $db + * @param IResultWrapper $res + */ + function preprocessResults( $db, $res ) { + $this->executeLBFromResultWrapper( $res ); + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialCachedPage.php b/www/wiki/includes/specials/SpecialCachedPage.php new file mode 100644 index 00000000..14c84e9d --- /dev/null +++ b/www/wiki/includes/specials/SpecialCachedPage.php @@ -0,0 +1,201 @@ +<?php + +/** + * Abstract special page class with scaffolding for caching HTML and other values + * in a single blob. + * + * Before using any of the caching functionality, call startCache. + * After the last call to either getCachedValue or addCachedHTML, call saveCache. + * + * To get a cached value or compute it, use getCachedValue like this: + * $this->getCachedValue( $callback ); + * + * To add HTML that should be cached, use addCachedHTML like this: + * $this->addCachedHTML( $callback ); + * + * The callback function is only called when needed, so do all your expensive + * computations here. This function should returns the HTML to be cached. + * It should not add anything to the PageOutput object! + * + * 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 + * @author Jeroen De Dauw < jeroendedauw@gmail.com > + * @since 1.20 + */ +abstract class SpecialCachedPage extends SpecialPage implements ICacheHelper { + /** + * CacheHelper object to which we forward the non-SpecialPage specific caching work. + * Initialized in startCache. + * + * @since 1.20 + * @var CacheHelper + */ + protected $cacheHelper; + + /** + * If the cache is enabled or not. + * + * @since 1.20 + * @var bool + */ + protected $cacheEnabled = true; + + /** + * Gets called after @see SpecialPage::execute. + * + * @since 1.20 + * + * @param string|null $subPage + */ + protected function afterExecute( $subPage ) { + $this->saveCache(); + + parent::afterExecute( $subPage ); + } + + /** + * Sets if the cache should be enabled or not. + * + * @since 1.20 + * @param bool $cacheEnabled + */ + public function setCacheEnabled( $cacheEnabled ) { + $this->cacheHelper->setCacheEnabled( $cacheEnabled ); + } + + /** + * Initializes the caching. + * Should be called before the first time anything is added via addCachedHTML. + * + * @since 1.20 + * + * @param int|null $cacheExpiry Sets the cache expiry, either ttl in seconds or unix timestamp. + * @param bool|null $cacheEnabled Sets if the cache should be enabled or not. + */ + public function startCache( $cacheExpiry = null, $cacheEnabled = null ) { + if ( !isset( $this->cacheHelper ) ) { + $this->cacheHelper = new CacheHelper(); + + $this->cacheHelper->setCacheEnabled( $this->cacheEnabled ); + $this->cacheHelper->setOnInitializedHandler( [ $this, 'onCacheInitialized' ] ); + + $keyArgs = $this->getCacheKey(); + + if ( array_key_exists( 'action', $keyArgs ) && $keyArgs['action'] === 'purge' ) { + unset( $keyArgs['action'] ); + } + + $this->cacheHelper->setCacheKey( $keyArgs ); + + if ( $this->getRequest()->getText( 'action' ) === 'purge' ) { + $this->cacheHelper->rebuildOnDemand(); + } + } + + $this->cacheHelper->startCache( $cacheExpiry, $cacheEnabled ); + } + + /** + * Get a cached value if available or compute it if not and then cache it if possible. + * The provided $computeFunction is only called when the computation needs to happen + * and should return a result value. $args are arguments that will be passed to the + * compute function when called. + * + * @since 1.20 + * + * @param callable $computeFunction + * @param array|mixed $args + * @param string|null $key + * + * @return mixed + */ + public function getCachedValue( $computeFunction, $args = [], $key = null ) { + return $this->cacheHelper->getCachedValue( $computeFunction, $args, $key ); + } + + /** + * Add some HTML to be cached. + * This is done by providing a callback function that should + * return the HTML to be added. It will only be called if the + * item is not in the cache yet or when the cache has been invalidated. + * + * @since 1.20 + * + * @param callable $computeFunction + * @param array $args + * @param string|null $key + */ + public function addCachedHTML( $computeFunction, $args = [], $key = null ) { + $this->getOutput()->addHTML( $this->cacheHelper->getCachedValue( + $computeFunction, + $args, + $key + ) ); + } + + /** + * Saves the HTML to the cache in case it got recomputed. + * Should be called after the last time anything is added via addCachedHTML. + * + * @since 1.20 + */ + public function saveCache() { + if ( isset( $this->cacheHelper ) ) { + $this->cacheHelper->saveCache(); + } + } + + /** + * Sets the time to live for the cache, in seconds or a unix timestamp + * indicating the point of expiry. + * + * @since 1.20 + * + * @param int $cacheExpiry + */ + public function setExpiry( $cacheExpiry ) { + $this->cacheHelper->setExpiry( $cacheExpiry ); + } + + /** + * Returns the variables used to constructed the cache key in an array. + * + * @since 1.20 + * + * @return array + */ + protected function getCacheKey() { + return [ + $this->mName, + $this->getLanguage()->getCode() + ]; + } + + /** + * Gets called after the cache got initialized. + * + * @since 1.20 + * + * @param bool $hasCached + */ + public function onCacheInitialized( $hasCached ) { + if ( $hasCached ) { + $this->getOutput()->setSubtitle( $this->cacheHelper->getCachedNotice( $this->getContext() ) ); + } + } +} diff --git a/www/wiki/includes/specials/SpecialCategories.php b/www/wiki/includes/specials/SpecialCategories.php new file mode 100644 index 00000000..84d1f7c7 --- /dev/null +++ b/www/wiki/includes/specials/SpecialCategories.php @@ -0,0 +1,65 @@ +<?php +/** + * Implements Special:Categories + * + * 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 SpecialCategories extends SpecialPage { + + public function __construct() { + parent::__construct( 'Categories' ); + + // Since we don't control the constructor parameters, we can't inject services that way. + // Instead, we initialize services in the execute() method, and allow them to be overridden + // using the initServices() method. + } + + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + $this->getOutput()->allowClickjacking(); + + $from = $this->getRequest()->getText( 'from', $par ); + + $cap = new CategoryPager( + $this->getContext(), + $from, + $this->getLinkRenderer() + ); + $cap->doQuery(); + + $this->getOutput()->addHTML( + Html::openElement( 'div', [ 'class' => 'mw-spcontent' ] ) . + $this->msg( 'categoriespagetext', $cap->getNumRows() )->parseAsBlock() . + $cap->getStartForm( $from ) . + $cap->getNavigationBar() . + '<ul>' . $cap->getBody() . '</ul>' . + $cap->getNavigationBar() . + Html::closeElement( 'div' ) + ); + } + + protected function getGroupName() { + return 'pages'; + } +} diff --git a/www/wiki/includes/specials/SpecialChangeContentModel.php b/www/wiki/includes/specials/SpecialChangeContentModel.php new file mode 100644 index 00000000..87c899f4 --- /dev/null +++ b/www/wiki/includes/specials/SpecialChangeContentModel.php @@ -0,0 +1,296 @@ +<?php + +class SpecialChangeContentModel extends FormSpecialPage { + + public function __construct() { + parent::__construct( 'ChangeContentModel', 'editcontentmodel' ); + } + + public function doesWrites() { + return true; + } + + /** + * @var Title|null + */ + private $title; + + /** + * @var Revision|bool|null + * + * A Revision object, false if no revision exists, null if not loaded yet + */ + private $oldRevision; + + protected function setParameter( $par ) { + $par = $this->getRequest()->getVal( 'pagetitle', $par ); + $title = Title::newFromText( $par ); + if ( $title ) { + $this->title = $title; + $this->par = $title->getPrefixedText(); + } else { + $this->par = ''; + } + } + + protected function postText() { + $text = ''; + if ( $this->title ) { + $contentModelLogPage = new LogPage( 'contentmodel' ); + $text = Xml::element( 'h2', null, $contentModelLogPage->getName()->text() ); + $out = ''; + LogEventsList::showLogExtract( $out, 'contentmodel', $this->title ); + $text .= $out; + } + return $text; + } + + protected function getDisplayFormat() { + return 'ooui'; + } + + protected function alterForm( HTMLForm $form ) { + if ( !$this->title ) { + $form->setMethod( 'GET' ); + } + + $this->addHelpLink( 'Help:ChangeContentModel' ); + + // T120576 + $form->setSubmitTextMsg( 'changecontentmodel-submit' ); + } + + public function validateTitle( $title ) { + if ( !$title ) { + // No form input yet + return true; + } + + // Already validated by HTMLForm, but if not, throw + // and exception instead of a fatal + $titleObj = Title::newFromTextThrow( $title ); + + $this->oldRevision = Revision::newFromTitle( $titleObj ) ?: false; + + if ( $this->oldRevision ) { + $oldContent = $this->oldRevision->getContent(); + if ( !$oldContent->getContentHandler()->supportsDirectEditing() ) { + return $this->msg( 'changecontentmodel-nodirectediting' ) + ->params( ContentHandler::getLocalizedName( $oldContent->getModel() ) ) + ->escaped(); + } + } + + return true; + } + + protected function getFormFields() { + $fields = [ + 'pagetitle' => [ + 'type' => 'title', + 'creatable' => true, + 'name' => 'pagetitle', + 'default' => $this->par, + 'label-message' => 'changecontentmodel-title-label', + 'validation-callback' => [ $this, 'validateTitle' ], + ], + ]; + if ( $this->title ) { + $options = $this->getOptionsForTitle( $this->title ); + if ( empty( $options ) ) { + throw new ErrorPageError( + 'changecontentmodel-emptymodels-title', + 'changecontentmodel-emptymodels-text', + $this->title->getPrefixedText() + ); + } + $fields['pagetitle']['readonly'] = true; + $fields += [ + 'model' => [ + 'type' => 'select', + 'name' => 'model', + 'options' => $options, + 'label-message' => 'changecontentmodel-model-label' + ], + 'reason' => [ + 'type' => 'text', + 'name' => 'reason', + 'validation-callback' => function ( $reason ) { + $match = EditPage::matchSummarySpamRegex( $reason ); + if ( $match ) { + return $this->msg( 'spamprotectionmatch', $match )->parse(); + } + + return true; + }, + 'label-message' => 'changecontentmodel-reason-label', + ], + ]; + } + + return $fields; + } + + private function getOptionsForTitle( Title $title = null ) { + $models = ContentHandler::getContentModels(); + $options = []; + foreach ( $models as $model ) { + $handler = ContentHandler::getForModelID( $model ); + if ( !$handler->supportsDirectEditing() ) { + continue; + } + if ( $title ) { + if ( $title->getContentModel() === $model ) { + continue; + } + if ( !$handler->canBeUsedOn( $title ) ) { + continue; + } + } + $options[ContentHandler::getLocalizedName( $model )] = $model; + } + + return $options; + } + + public function onSubmit( array $data ) { + if ( $data['pagetitle'] === '' ) { + // Initial form view of special page, pass + return false; + } + + // At this point, it has to be a POST request. This is enforced by HTMLForm, + // but lets be safe verify that. + if ( !$this->getRequest()->wasPosted() ) { + throw new RuntimeException( "Form submission was not POSTed" ); + } + + $this->title = Title::newFromText( $data['pagetitle'] ); + $titleWithNewContentModel = clone $this->title; + $titleWithNewContentModel->setContentModel( $data['model'] ); + $user = $this->getUser(); + // Check permissions and make sure the user has permission to: + $errors = wfMergeErrorArrays( + // edit the contentmodel of the page + $this->title->getUserPermissionsErrors( 'editcontentmodel', $user ), + // edit the page under the old content model + $this->title->getUserPermissionsErrors( 'edit', $user ), + // edit the contentmodel under the new content model + $titleWithNewContentModel->getUserPermissionsErrors( 'editcontentmodel', $user ), + // edit the page under the new content model + $titleWithNewContentModel->getUserPermissionsErrors( 'edit', $user ) + ); + if ( $errors ) { + $out = $this->getOutput(); + $wikitext = $out->formatPermissionsErrorMessage( $errors ); + // Hack to get our wikitext parsed + return Status::newFatal( new RawMessage( '$1', [ $wikitext ] ) ); + } + + $page = WikiPage::factory( $this->title ); + if ( $this->oldRevision === null ) { + $this->oldRevision = $page->getRevision() ?: false; + } + $oldModel = $this->title->getContentModel(); + if ( $this->oldRevision ) { + $oldContent = $this->oldRevision->getContent(); + try { + $newContent = ContentHandler::makeContent( + $oldContent->serialize(), $this->title, $data['model'] + ); + } catch ( MWException $e ) { + return Status::newFatal( + $this->msg( 'changecontentmodel-cannot-convert' ) + ->params( + $this->title->getPrefixedText(), + ContentHandler::getLocalizedName( $data['model'] ) + ) + ); + } + } else { + // Page doesn't exist, create an empty content object + $newContent = ContentHandler::getForModelID( $data['model'] )->makeEmptyContent(); + } + + // All other checks have passed, let's check rate limits + if ( $user->pingLimiter( 'editcontentmodel' ) ) { + throw new ThrottledError(); + } + + $flags = $this->oldRevision ? EDIT_UPDATE : EDIT_NEW; + $flags |= EDIT_INTERNAL; + if ( $user->isAllowed( 'bot' ) ) { + $flags |= EDIT_FORCE_BOT; + } + + $log = new ManualLogEntry( 'contentmodel', $this->oldRevision ? 'change' : 'new' ); + $log->setPerformer( $user ); + $log->setTarget( $this->title ); + $log->setComment( $data['reason'] ); + $log->setParameters( [ + '4::oldmodel' => $oldModel, + '5::newmodel' => $data['model'] + ] ); + + $formatter = LogFormatter::newFromEntry( $log ); + $formatter->setContext( RequestContext::newExtraneousContext( $this->title ) ); + $reason = $formatter->getPlainActionText(); + if ( $data['reason'] !== '' ) { + $reason .= $this->msg( 'colon-separator' )->inContentLanguage()->text() . $data['reason']; + } + + // Run edit filters + $derivativeContext = new DerivativeContext( $this->getContext() ); + $derivativeContext->setTitle( $this->title ); + $derivativeContext->setWikiPage( $page ); + $status = new Status(); + if ( !Hooks::run( 'EditFilterMergedContent', + [ $derivativeContext, $newContent, $status, $reason, + $user, false ] ) + ) { + if ( $status->isGood() ) { + // TODO: extensions should really specify an error message + $status->fatal( 'hookaborted' ); + } + return $status; + } + + $status = $page->doEditContent( + $newContent, + $reason, + $flags, + $this->oldRevision ? $this->oldRevision->getId() : false, + $user + ); + if ( !$status->isOK() ) { + return $status; + } + + $logid = $log->insert(); + $log->publish( $logid ); + + return $status; + } + + public function onSuccess() { + $out = $this->getOutput(); + $out->setPageTitle( $this->msg( 'changecontentmodel-success-title' ) ); + $out->addWikiMsg( 'changecontentmodel-success-text', $this->title ); + } + + /** + * 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/www/wiki/includes/specials/SpecialChangeCredentials.php b/www/wiki/includes/specials/SpecialChangeCredentials.php new file mode 100644 index 00000000..970a2e29 --- /dev/null +++ b/www/wiki/includes/specials/SpecialChangeCredentials.php @@ -0,0 +1,267 @@ +<?php + +use MediaWiki\Auth\AuthenticationRequest; +use MediaWiki\Auth\AuthenticationResponse; +use MediaWiki\Auth\AuthManager; +use MediaWiki\Session\SessionManager; + +/** + * Special change to change credentials (such as the password). + * + * Also does most of the work for SpecialRemoveCredentials. + */ +class SpecialChangeCredentials extends AuthManagerSpecialPage { + protected static $allowedActions = [ AuthManager::ACTION_CHANGE ]; + + protected static $messagePrefix = 'changecredentials'; + + /** Change action needs user data; remove action does not */ + protected static $loadUserData = true; + + public function __construct( $name = 'ChangeCredentials' ) { + parent::__construct( $name, 'editmyprivateinfo' ); + } + + protected function getGroupName() { + return 'users'; + } + + public function isListed() { + $this->loadAuth( '' ); + return (bool)$this->authRequests; + } + + public function doesWrites() { + return true; + } + + protected function getDefaultAction( $subPage ) { + return AuthManager::ACTION_CHANGE; + } + + protected function getPreservedParams( $withToken = false ) { + $request = $this->getRequest(); + $params = parent::getPreservedParams( $withToken ); + $params += [ + 'returnto' => $request->getVal( 'returnto' ), + 'returntoquery' => $request->getVal( 'returntoquery' ), + ]; + return $params; + } + + public function onAuthChangeFormFields( + array $requests, array $fieldInfo, array &$formDescriptor, $action + ) { + // This method is never called for remove actions. + + $extraFields = []; + Hooks::run( 'ChangePasswordForm', [ &$extraFields ], '1.27' ); + foreach ( $extraFields as $extra ) { + list( $name, $label, $type, $default ) = $extra; + $formDescriptor[$name] = [ + 'type' => $type, + 'name' => $name, + 'label-message' => $label, + 'default' => $default, + ]; + + } + + return parent::onAuthChangeFormFields( $requests, $fieldInfo, $formDescriptor, $action ); + } + + public function execute( $subPage ) { + $this->setHeaders(); + $this->outputHeader(); + + $this->loadAuth( $subPage ); + + if ( !$subPage ) { + $this->showSubpageList(); + return; + } + + if ( !$this->authRequests ) { + // messages used: changecredentials-invalidsubpage, removecredentials-invalidsubpage + $this->showSubpageList( $this->msg( static::$messagePrefix . '-invalidsubpage', $subPage ) ); + return; + } + + $this->getOutput()->addBacklinkSubtitle( $this->getPageTitle() ); + + $status = $this->trySubmit(); + + if ( $status === false || !$status->isOK() ) { + $this->displayForm( $status ); + return; + } + + $response = $status->getValue(); + + switch ( $response->status ) { + case AuthenticationResponse::PASS: + $this->success(); + break; + case AuthenticationResponse::FAIL: + $this->displayForm( Status::newFatal( $response->message ) ); + break; + default: + throw new LogicException( 'invalid AuthenticationResponse' ); + } + } + + protected function loadAuth( $subPage, $authAction = null, $reset = false ) { + parent::loadAuth( $subPage, $authAction ); + if ( $subPage ) { + $this->authRequests = array_filter( $this->authRequests, function ( $req ) use ( $subPage ) { + return $req->getUniqueId() === $subPage; + } ); + if ( count( $this->authRequests ) > 1 ) { + throw new LogicException( 'Multiple AuthenticationRequest objects with same ID!' ); + } + } + } + + protected function getAuthFormDescriptor( $requests, $action ) { + if ( !static::$loadUserData ) { + return []; + } else { + $descriptor = parent::getAuthFormDescriptor( $requests, $action ); + + $any = false; + foreach ( $descriptor as &$field ) { + if ( $field['type'] === 'password' && $field['name'] !== 'retype' ) { + $any = true; + if ( isset( $field['cssclass'] ) ) { + $field['cssclass'] .= ' mw-changecredentials-validate-password'; + } else { + $field['cssclass'] = 'mw-changecredentials-validate-password'; + } + } + } + + if ( $any ) { + $this->getOutput()->addModules( [ + 'mediawiki.special.changecredentials.js' + ] ); + } + + return $descriptor; + } + } + + protected function getAuthForm( array $requests, $action ) { + $form = parent::getAuthForm( $requests, $action ); + $req = reset( $requests ); + $info = $req->describeCredentials(); + + $form->addPreText( + Html::openElement( 'dl' ) + . Html::element( 'dt', [], wfMessage( 'credentialsform-provider' )->text() ) + . Html::element( 'dd', [], $info['provider'] ) + . Html::element( 'dt', [], wfMessage( 'credentialsform-account' )->text() ) + . Html::element( 'dd', [], $info['account'] ) + . Html::closeElement( 'dl' ) + ); + + // messages used: changecredentials-submit removecredentials-submit + $form->setSubmitTextMsg( static::$messagePrefix . '-submit' ); + $form->showCancel()->setCancelTarget( $this->getReturnUrl() ?: Title::newMainPage() ); + + return $form; + } + + protected function needsSubmitButton( array $requests ) { + // Change/remove forms show are built from a single AuthenticationRequest and do not allow + // for redirect flow; they always need a submit button. + return true; + } + + public function handleFormSubmit( $data ) { + // remove requests do not accept user input + $requests = $this->authRequests; + if ( static::$loadUserData ) { + $requests = AuthenticationRequest::loadRequestsFromSubmission( $this->authRequests, $data ); + } + + $response = $this->performAuthenticationStep( $this->authAction, $requests ); + + // we can't handle FAIL or similar as failure here since it might require changing the form + return Status::newGood( $response ); + } + + /** + * @param Message|null $error + */ + protected function showSubpageList( $error = null ) { + $out = $this->getOutput(); + + if ( $error ) { + $out->addHTML( $error->parse() ); + } + + $groupedRequests = []; + foreach ( $this->authRequests as $req ) { + $info = $req->describeCredentials(); + $groupedRequests[(string)$info['provider']][] = $req; + } + + $linkRenderer = $this->getLinkRenderer(); + $out->addHTML( Html::openElement( 'dl' ) ); + foreach ( $groupedRequests as $group => $members ) { + $out->addHTML( Html::element( 'dt', [], $group ) ); + foreach ( $members as $req ) { + /** @var AuthenticationRequest $req */ + $info = $req->describeCredentials(); + $out->addHTML( Html::rawElement( 'dd', [], + $linkRenderer->makeLink( + $this->getPageTitle( $req->getUniqueId() ), + $info['account'] + ) + ) ); + } + } + $out->addHTML( Html::closeElement( 'dl' ) ); + } + + protected function success() { + $session = $this->getRequest()->getSession(); + $user = $this->getUser(); + $out = $this->getOutput(); + $returnUrl = $this->getReturnUrl(); + + // change user token and update the session + SessionManager::singleton()->invalidateSessionsForUser( $user ); + $session->setUser( $user ); + $session->resetId(); + + if ( $returnUrl ) { + $out->redirect( $returnUrl ); + } else { + // messages used: changecredentials-success removecredentials-success + $out->wrapWikiMsg( "<div class=\"successbox\">\n$1\n</div>", static::$messagePrefix + . '-success' ); + $out->returnToMain(); + } + } + + /** + * @return string|null + */ + protected function getReturnUrl() { + $request = $this->getRequest(); + $returnTo = $request->getText( 'returnto' ); + $returnToQuery = $request->getText( 'returntoquery', '' ); + + if ( !$returnTo ) { + return null; + } + + $title = Title::newFromText( $returnTo ); + return $title->getFullUrlForRedirect( $returnToQuery ); + } + + protected function getRequestBlacklist() { + return $this->getConfig()->get( 'ChangeCredentialsBlacklist' ); + } +} diff --git a/www/wiki/includes/specials/SpecialChangeEmail.php b/www/wiki/includes/specials/SpecialChangeEmail.php new file mode 100644 index 00000000..05f8022f --- /dev/null +++ b/www/wiki/includes/specials/SpecialChangeEmail.php @@ -0,0 +1,206 @@ +<?php +/** + * Implements Special:ChangeEmail + * + * 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 + */ + +use MediaWiki\Auth\AuthManager; +use MediaWiki\Logger\LoggerFactory; + +/** + * Let users change their email address. + * + * @ingroup SpecialPage + */ +class SpecialChangeEmail extends FormSpecialPage { + /** + * @var Status + */ + private $status; + + public function __construct() { + parent::__construct( 'ChangeEmail', 'editmyprivateinfo' ); + } + + public function doesWrites() { + return true; + } + + /** + * @return bool + */ + public function isListed() { + return AuthManager::singleton()->allowsPropertyChange( 'emailaddress' ); + } + + /** + * Main execution point + * @param string $par + */ + function execute( $par ) { + $out = $this->getOutput(); + $out->disallowUserJs(); + + parent::execute( $par ); + } + + protected function getLoginSecurityLevel() { + return $this->getName(); + } + + protected function checkExecutePermissions( User $user ) { + if ( !AuthManager::singleton()->allowsPropertyChange( 'emailaddress' ) ) { + throw new ErrorPageError( 'changeemail', 'cannotchangeemail' ); + } + + $this->requireLogin( 'changeemail-no-info' ); + + // This could also let someone check the current email address, so + // require both permissions. + if ( !$this->getUser()->isAllowed( 'viewmyprivateinfo' ) ) { + throw new PermissionsError( 'viewmyprivateinfo' ); + } + + if ( $user->isBlockedFromEmailuser() ) { + throw new UserBlockedError( $user->getBlock() ); + } + + parent::checkExecutePermissions( $user ); + } + + protected function getFormFields() { + $user = $this->getUser(); + + $fields = [ + 'Name' => [ + 'type' => 'info', + 'label-message' => 'username', + 'default' => $user->getName(), + ], + 'OldEmail' => [ + 'type' => 'info', + 'label-message' => 'changeemail-oldemail', + 'default' => $user->getEmail() ?: $this->msg( 'changeemail-none' )->text(), + ], + 'NewEmail' => [ + 'type' => 'email', + 'label-message' => 'changeemail-newemail', + 'autofocus' => true, + 'help-message' => 'changeemail-newemail-help', + ], + ]; + + return $fields; + } + + protected function getDisplayFormat() { + return 'ooui'; + } + + protected function alterForm( HTMLForm $form ) { + $form->setId( 'mw-changeemail-form' ); + $form->setTableId( 'mw-changeemail-table' ); + $form->setSubmitTextMsg( 'changeemail-submit' ); + $form->addHiddenFields( $this->getRequest()->getValues( 'returnto', 'returntoquery' ) ); + + $form->addHeaderText( $this->msg( 'changeemail-header' )->parseAsBlock() ); + } + + public function onSubmit( array $data ) { + $status = $this->attemptChange( $this->getUser(), $data['NewEmail'] ); + + $this->status = $status; + + return $status; + } + + public function onSuccess() { + $request = $this->getRequest(); + + $returnto = $request->getVal( 'returnto' ); + $titleObj = $returnto !== null ? Title::newFromText( $returnto ) : null; + if ( !$titleObj instanceof Title ) { + $titleObj = Title::newMainPage(); + } + $query = $request->getVal( 'returntoquery' ); + + if ( $this->status->value === true ) { + $this->getOutput()->redirect( $titleObj->getFullUrlForRedirect( $query ) ); + } elseif ( $this->status->value === 'eauth' ) { + # Notify user that a confirmation email has been sent... + $this->getOutput()->wrapWikiMsg( "<div class='error' style='clear: both;'>\n$1\n</div>", + 'eauthentsent', $this->getUser()->getName() ); + // just show the link to go back + $this->getOutput()->addReturnTo( $titleObj, wfCgiToArray( $query ) ); + } + } + + /** + * @param User $user + * @param string $newaddr + * @return Status + */ + private function attemptChange( User $user, $newaddr ) { + $authManager = AuthManager::singleton(); + + if ( $newaddr != '' && !Sanitizer::validateEmail( $newaddr ) ) { + return Status::newFatal( 'invalidemailaddress' ); + } + + if ( $newaddr === $user->getEmail() ) { + return Status::newFatal( 'changeemail-nochange' ); + } + + // To prevent spam, rate limit adding a new address, but do + // not rate limit removing an address. + if ( $newaddr !== '' && $user->pingLimiter( 'changeemail' ) ) { + return Status::newFatal( 'actionthrottledtext' ); + } + + $oldaddr = $user->getEmail(); + $status = $user->setEmailWithConfirmation( $newaddr ); + if ( !$status->isGood() ) { + return $status; + } + + LoggerFactory::getInstance( 'authentication' )->info( + 'Changing email address for {user} from {oldemail} to {newemail}', [ + 'user' => $user->getName(), + 'oldemail' => $oldaddr, + 'newemail' => $newaddr, + ] + ); + + Hooks::run( 'PrefsEmailAudit', [ $user, $oldaddr, $newaddr ] ); + + $user->saveSettings(); + MediaWiki\Auth\AuthManager::callLegacyAuthPlugin( 'updateExternalDB', [ $user ] ); + + return $status; + } + + public function requiresUnblock() { + return false; + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/www/wiki/includes/specials/SpecialChangePassword.php b/www/wiki/includes/specials/SpecialChangePassword.php new file mode 100644 index 00000000..ce769bfd --- /dev/null +++ b/www/wiki/includes/specials/SpecialChangePassword.php @@ -0,0 +1,36 @@ +<?php +/** + * Implements Special:ChangePassword + * + * 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 + */ + +use MediaWiki\Auth\PasswordAuthenticationRequest; + +/** + * Let users recover their password. + * + * @ingroup SpecialPage + */ +class SpecialChangePassword extends SpecialRedirectToSpecial { + public function __construct() { + parent::__construct( 'ChangePassword', 'ChangeCredentials', + PasswordAuthenticationRequest::class, [ 'returnto', 'returntoquery' ] ); + } +} diff --git a/www/wiki/includes/specials/SpecialComparePages.php b/www/wiki/includes/specials/SpecialComparePages.php new file mode 100644 index 00000000..35cc6b84 --- /dev/null +++ b/www/wiki/includes/specials/SpecialComparePages.php @@ -0,0 +1,174 @@ +<?php +/** + * Implements Special:ComparePages + * + * Copyright © 2010 Derk-Jan Hartman <hartman@videolan.org> + * + * 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 + */ + +/** + * Implements Special:ComparePages + * + * @ingroup SpecialPage + */ +class SpecialComparePages extends SpecialPage { + + // Stored objects + protected $opts, $skin; + + // Some internal settings + protected $showNavigation = false; + + public function __construct() { + parent::__construct( 'ComparePages' ); + } + + /** + * Show a form for filtering namespace and username + * + * @param string $par + * @return string + */ + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + $this->getOutput()->addModuleStyles( 'mediawiki.special.comparepages.styles' ); + + $form = HTMLForm::factory( 'ooui', [ + 'Page1' => [ + 'type' => 'title', + 'name' => 'page1', + 'label-message' => 'compare-page1', + 'size' => '40', + 'section' => 'page1', + 'validation-callback' => [ $this, 'checkExistingTitle' ], + ], + 'Revision1' => [ + 'type' => 'int', + 'name' => 'rev1', + 'label-message' => 'compare-rev1', + 'size' => '8', + 'section' => 'page1', + 'validation-callback' => [ $this, 'checkExistingRevision' ], + ], + 'Page2' => [ + 'type' => 'title', + 'name' => 'page2', + 'label-message' => 'compare-page2', + 'size' => '40', + 'section' => 'page2', + 'validation-callback' => [ $this, 'checkExistingTitle' ], + ], + 'Revision2' => [ + 'type' => 'int', + 'name' => 'rev2', + 'label-message' => 'compare-rev2', + 'size' => '8', + 'section' => 'page2', + 'validation-callback' => [ $this, 'checkExistingRevision' ], + ], + 'Action' => [ + 'type' => 'hidden', + 'name' => 'action', + ], + 'Diffonly' => [ + 'type' => 'hidden', + 'name' => 'diffonly', + ], + 'Unhide' => [ + 'type' => 'hidden', + 'name' => 'unhide', + ], + ], $this->getContext(), 'compare' ); + $form->setSubmitTextMsg( 'compare-submit' ); + $form->suppressReset(); + $form->setMethod( 'get' ); + $form->setSubmitCallback( [ __CLASS__, 'showDiff' ] ); + + $form->loadData(); + $form->displayForm( '' ); + $form->trySubmit(); + } + + public static function showDiff( $data, HTMLForm $form ) { + $rev1 = self::revOrTitle( $data['Revision1'], $data['Page1'] ); + $rev2 = self::revOrTitle( $data['Revision2'], $data['Page2'] ); + + if ( $rev1 && $rev2 ) { + $revision = Revision::newFromId( $rev1 ); + + if ( $revision ) { // NOTE: $rev1 was already checked, should exist. + $contentHandler = $revision->getContentHandler(); + $de = $contentHandler->createDifferenceEngine( $form->getContext(), + $rev1, + $rev2, + null, // rcid + ( $data['Action'] == 'purge' ), + ( $data['Unhide'] == '1' ) + ); + $de->showDiffPage( true ); + } + } + } + + public static function revOrTitle( $revision, $title ) { + if ( $revision ) { + return $revision; + } elseif ( $title ) { + $title = Title::newFromText( $title ); + if ( $title instanceof Title ) { + return $title->getLatestRevID(); + } + } + + return null; + } + + public function checkExistingTitle( $value, $alldata ) { + if ( $value === '' || $value === null ) { + return true; + } + $title = Title::newFromText( $value ); + if ( !$title instanceof Title ) { + return $this->msg( 'compare-invalid-title' )->parseAsBlock(); + } + if ( !$title->exists() ) { + return $this->msg( 'compare-title-not-exists' )->parseAsBlock(); + } + + return true; + } + + public function checkExistingRevision( $value, $alldata ) { + if ( $value === '' || $value === null ) { + return true; + } + $revision = Revision::newFromId( $value ); + if ( $revision === null ) { + return $this->msg( 'compare-revision-not-exists' )->parseAsBlock(); + } + + return true; + } + + protected function getGroupName() { + return 'pagetools'; + } +} diff --git a/www/wiki/includes/specials/SpecialConfirmemail.php b/www/wiki/includes/specials/SpecialConfirmemail.php new file mode 100644 index 00000000..f494b9d6 --- /dev/null +++ b/www/wiki/includes/specials/SpecialConfirmemail.php @@ -0,0 +1,168 @@ +<?php +/** + * Implements Special:Confirmemail + * + * 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 + */ + +/** + * Special page allows users to request email confirmation message, and handles + * processing of the confirmation code when the link in the email is followed + * + * @ingroup SpecialPage + * @author Brion Vibber + * @author Rob Church <robchur@gmail.com> + */ +class EmailConfirmation extends UnlistedSpecialPage { + public function __construct() { + parent::__construct( 'Confirmemail', 'editmyprivateinfo' ); + } + + public function doesWrites() { + return true; + } + + /** + * Main execution point + * + * @param null|string $code Confirmation code passed to the page + * @throws PermissionsError + * @throws ReadOnlyError + * @throws UserNotLoggedIn + */ + function execute( $code ) { + // Ignore things like master queries/connections on GET requests. + // It's very convenient to just allow formless link usage. + $trxProfiler = Profiler::instance()->getTransactionProfiler(); + + $this->setHeaders(); + $this->checkReadOnly(); + $this->checkPermissions(); + + // This could also let someone check the current email address, so + // require both permissions. + if ( !$this->getUser()->isAllowed( 'viewmyprivateinfo' ) ) { + throw new PermissionsError( 'viewmyprivateinfo' ); + } + + if ( $code === null || $code === '' ) { + $this->requireLogin( 'confirmemail_needlogin' ); + if ( Sanitizer::validateEmail( $this->getUser()->getEmail() ) ) { + $this->showRequestForm(); + } else { + $this->getOutput()->addWikiMsg( 'confirmemail_noemail' ); + } + } else { + $old = $trxProfiler->setSilenced( true ); + $this->attemptConfirm( $code ); + $trxProfiler->setSilenced( $old ); + } + } + + /** + * Show a nice form for the user to request a confirmation mail + */ + function showRequestForm() { + $user = $this->getUser(); + $out = $this->getOutput(); + + if ( !$user->isEmailConfirmed() ) { + $descriptor = []; + if ( $user->isEmailConfirmationPending() ) { + $descriptor += [ + 'pending' => [ + 'type' => 'info', + 'raw' => true, + 'default' => "<div class=\"error mw-confirmemail-pending\">\n" . + $this->msg( 'confirmemail_pending' )->escaped() . + "\n</div>", + ], + ]; + } + + $out->addWikiMsg( 'confirmemail_text' ); + $form = HTMLForm::factory( 'ooui', $descriptor, $this->getContext() ); + $form + ->setMethod( 'post' ) + ->setAction( $this->getPageTitle()->getLocalURL() ) + ->setSubmitTextMsg( 'confirmemail_send' ) + ->setSubmitCallback( [ $this, 'submitSend' ] ); + + $retval = $form->show(); + + if ( $retval === true ) { + // should never happen, but if so, don't let the user without any message + $out->addWikiMsg( 'confirmemail_sent' ); + } elseif ( $retval instanceof Status && $retval->isGood() ) { + $out->addWikiText( $retval->getValue() ); + } + } else { + // date and time are separate parameters to facilitate localisation. + // $time is kept for backward compat reasons. + // 'emailauthenticated' is also used in SpecialPreferences.php + $lang = $this->getLanguage(); + $emailAuthenticated = $user->getEmailAuthenticationTimestamp(); + $time = $lang->userTimeAndDate( $emailAuthenticated, $user ); + $d = $lang->userDate( $emailAuthenticated, $user ); + $t = $lang->userTime( $emailAuthenticated, $user ); + $out->addWikiMsg( 'emailauthenticated', $time, $d, $t ); + } + } + + /** + * Callback for HTMLForm send confirmation mail. + * + * @return Status Status object with the result + */ + public function submitSend() { + $status = $this->getUser()->sendConfirmationMail(); + if ( $status->isGood() ) { + return Status::newGood( $this->msg( 'confirmemail_sent' )->text() ); + } else { + return Status::newFatal( new RawMessage( + $status->getWikiText( 'confirmemail_sendfailed' ) + ) ); + } + } + + /** + * Attempt to confirm the user's email address and show success or failure + * as needed; if successful, take the user to log in + * + * @param string $code Confirmation code + */ + private function attemptConfirm( $code ) { + $user = User::newFromConfirmationCode( $code, User::READ_LATEST ); + if ( !is_object( $user ) ) { + $this->getOutput()->addWikiMsg( 'confirmemail_invalid' ); + + return; + } + + $user->confirmEmail(); + $user->saveSettings(); + $message = $this->getUser()->isLoggedIn() ? 'confirmemail_loggedin' : 'confirmemail_success'; + $this->getOutput()->addWikiMsg( $message ); + + if ( !$this->getUser()->isLoggedIn() ) { + $title = SpecialPage::getTitleFor( 'Userlogin' ); + $this->getOutput()->returnToMain( true, $title ); + } + } +} diff --git a/www/wiki/includes/specials/SpecialContributions.php b/www/wiki/includes/specials/SpecialContributions.php new file mode 100644 index 00000000..6fc8306a --- /dev/null +++ b/www/wiki/includes/specials/SpecialContributions.php @@ -0,0 +1,780 @@ +<?php +/** + * Implements Special:Contributions + * + * 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 + */ + +use MediaWiki\MediaWikiServices; +use MediaWiki\Widget\DateInputWidget; + +/** + * Special:Contributions, show user contributions in a paged list + * + * @ingroup SpecialPage + */ +class SpecialContributions extends IncludableSpecialPage { + protected $opts; + + public function __construct() { + parent::__construct( 'Contributions' ); + } + + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + $out = $this->getOutput(); + // Modules required for viewing the list of contributions (also when included on other pages) + $out->addModuleStyles( [ + 'mediawiki.special', + 'mediawiki.special.changeslist', + ] ); + $this->addHelpLink( 'Help:User contributions' ); + + $this->opts = []; + $request = $this->getRequest(); + + if ( $par !== null ) { + $target = $par; + } else { + $target = $request->getVal( 'target' ); + } + + if ( $request->getVal( 'contribs' ) == 'newbie' || $par === 'newbies' ) { + $target = 'newbies'; + $this->opts['contribs'] = 'newbie'; + } else { + $this->opts['contribs'] = 'user'; + } + + $this->opts['deletedOnly'] = $request->getBool( 'deletedOnly' ); + + if ( !strlen( $target ) ) { + if ( !$this->including() ) { + $out->addHTML( $this->getForm() ); + } + + return; + } + + $user = $this->getUser(); + + $this->opts['limit'] = $request->getInt( 'limit', $user->getOption( 'rclimit' ) ); + $this->opts['target'] = $target; + $this->opts['topOnly'] = $request->getBool( 'topOnly' ); + $this->opts['newOnly'] = $request->getBool( 'newOnly' ); + $this->opts['hideMinor'] = $request->getBool( 'hideMinor' ); + + $id = 0; + if ( $this->opts['contribs'] === 'newbie' ) { + $userObj = User::newFromName( $target ); // hysterical raisins + $out->addSubtitle( $this->msg( 'sp-contributions-newbies-sub' ) ); + $out->setHTMLTitle( $this->msg( + 'pagetitle', + $this->msg( 'sp-contributions-newbies-title' )->plain() + )->inContentLanguage() ); + } elseif ( ExternalUserNames::isExternal( $target ) ) { + $userObj = User::newFromName( $target, false ); + if ( !$userObj ) { + $out->addHTML( $this->getForm() ); + return; + } + + $out->addSubtitle( $this->contributionsSub( $userObj ) ); + $out->setHTMLTitle( $this->msg( + 'pagetitle', + $this->msg( 'contributions-title', $target )->plain() + )->inContentLanguage() ); + } else { + $nt = Title::makeTitleSafe( NS_USER, $target ); + if ( !$nt ) { + $out->addHTML( $this->getForm() ); + return; + } + $userObj = User::newFromName( $nt->getText(), false ); + if ( !$userObj ) { + $out->addHTML( $this->getForm() ); + return; + } + $id = $userObj->getId(); + + $target = $nt->getText(); + $out->addSubtitle( $this->contributionsSub( $userObj ) ); + $out->setHTMLTitle( $this->msg( + 'pagetitle', + $this->msg( 'contributions-title', $target )->plain() + )->inContentLanguage() ); + + # For IP ranges, we want the contributionsSub, but not the skin-dependent + # links under 'Tools', which may include irrelevant links like 'Logs'. + if ( !IP::isValidRange( $target ) ) { + $this->getSkin()->setRelevantUser( $userObj ); + } + } + + $ns = $request->getVal( 'namespace', null ); + if ( $ns !== null && $ns !== '' ) { + $this->opts['namespace'] = intval( $ns ); + } else { + $this->opts['namespace'] = ''; + } + + $this->opts['associated'] = $request->getBool( 'associated' ); + $this->opts['nsInvert'] = (bool)$request->getVal( 'nsInvert' ); + $this->opts['tagfilter'] = (string)$request->getVal( 'tagfilter' ); + + // Allows reverts to have the bot flag in recent changes. It is just here to + // be passed in the form at the top of the page + if ( $user->isAllowed( 'markbotedits' ) && $request->getBool( 'bot' ) ) { + $this->opts['bot'] = '1'; + } + + $skip = $request->getText( 'offset' ) || $request->getText( 'dir' ) == 'prev'; + # Offset overrides year/month selection + if ( !$skip ) { + $this->opts['year'] = $request->getVal( 'year' ); + $this->opts['month'] = $request->getVal( 'month' ); + + $this->opts['start'] = $request->getVal( 'start' ); + $this->opts['end'] = $request->getVal( 'end' ); + } + $this->opts = ContribsPager::processDateFilter( $this->opts ); + + $feedType = $request->getVal( 'feed' ); + + $feedParams = [ + 'action' => 'feedcontributions', + 'user' => $target, + ]; + if ( $this->opts['topOnly'] ) { + $feedParams['toponly'] = true; + } + if ( $this->opts['newOnly'] ) { + $feedParams['newonly'] = true; + } + if ( $this->opts['hideMinor'] ) { + $feedParams['hideminor'] = true; + } + if ( $this->opts['deletedOnly'] ) { + $feedParams['deletedonly'] = true; + } + if ( $this->opts['tagfilter'] !== '' ) { + $feedParams['tagfilter'] = $this->opts['tagfilter']; + } + if ( $this->opts['namespace'] !== '' ) { + $feedParams['namespace'] = $this->opts['namespace']; + } + // Don't use year and month for the feed URL, but pass them on if + // we redirect to API (if $feedType is specified) + if ( $feedType && $this->opts['year'] !== null ) { + $feedParams['year'] = $this->opts['year']; + } + if ( $feedType && $this->opts['month'] !== null ) { + $feedParams['month'] = $this->opts['month']; + } + + if ( $feedType ) { + // Maintain some level of backwards compatibility + // If people request feeds using the old parameters, redirect to API + $feedParams['feedformat'] = $feedType; + $url = wfAppendQuery( wfScript( 'api' ), $feedParams ); + + $out->redirect( $url, '301' ); + + return; + } + + // Add RSS/atom links + $this->addFeedLinks( $feedParams ); + + if ( Hooks::run( 'SpecialContributionsBeforeMainOutput', [ $id, $userObj, $this ] ) ) { + if ( !$this->including() ) { + $out->addHTML( $this->getForm() ); + } + $pager = new ContribsPager( $this->getContext(), [ + 'target' => $target, + 'contribs' => $this->opts['contribs'], + 'namespace' => $this->opts['namespace'], + 'tagfilter' => $this->opts['tagfilter'], + 'start' => $this->opts['start'], + 'end' => $this->opts['end'], + 'deletedOnly' => $this->opts['deletedOnly'], + 'topOnly' => $this->opts['topOnly'], + 'newOnly' => $this->opts['newOnly'], + 'hideMinor' => $this->opts['hideMinor'], + 'nsInvert' => $this->opts['nsInvert'], + 'associated' => $this->opts['associated'], + ] ); + + if ( IP::isValidRange( $target ) && !$pager->isQueryableRange( $target ) ) { + // Valid range, but outside CIDR limit. + $limits = $this->getConfig()->get( 'RangeContributionsCIDRLimit' ); + $limit = $limits[ IP::isIPv4( $target ) ? 'IPv4' : 'IPv6' ]; + $out->addWikiMsg( 'sp-contributions-outofrange', $limit ); + } elseif ( !$pager->getNumRows() ) { + $out->addWikiMsg( 'nocontribs', $target ); + } else { + # Show a message about replica DB lag, if applicable + $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + $lag = $lb->safeGetLag( $pager->getDatabase() ); + if ( $lag > 0 ) { + $out->showLagWarning( $lag ); + } + + $output = $pager->getBody(); + if ( !$this->including() ) { + $output = '<p>' . $pager->getNavigationBar() . '</p>' . + $output . + '<p>' . $pager->getNavigationBar() . '</p>'; + } + $out->addHTML( $output ); + } + + $out->preventClickjacking( $pager->getPreventClickjacking() ); + + # Show the appropriate "footer" message - WHOIS tools, etc. + if ( $this->opts['contribs'] == 'newbie' ) { + $message = 'sp-contributions-footer-newbies'; + } elseif ( IP::isValidRange( $target ) ) { + $message = 'sp-contributions-footer-anon-range'; + } elseif ( IP::isIPAddress( $target ) ) { + $message = 'sp-contributions-footer-anon'; + } elseif ( $userObj->isAnon() ) { + // No message for non-existing users + $message = ''; + } else { + $message = 'sp-contributions-footer'; + } + + if ( $message ) { + if ( !$this->including() ) { + if ( !$this->msg( $message, $target )->isDisabled() ) { + $out->wrapWikiMsg( + "<div class='mw-contributions-footer'>\n$1\n</div>", + [ $message, $target ] ); + } + } + } + } + } + + /** + * Generates the subheading with links + * @param User $userObj User object for the target + * @return string Appropriately-escaped HTML to be output literally + * @todo FIXME: Almost the same as getSubTitle in SpecialDeletedContributions.php. + * Could be combined. + */ + protected function contributionsSub( $userObj ) { + if ( $userObj->isAnon() ) { + // Show a warning message that the user being searched for doesn't exists. + // User::isIP returns true for IP address and usemod IPs like '123.123.123.xxx', + // but returns false for IP ranges. We don't want to suggest either of these are + // valid usernames which we would with the 'contributions-userdoesnotexist' message. + if ( !User::isIP( $userObj->getName() ) && !$userObj->isIPRange() ) { + $this->getOutput()->wrapWikiMsg( + "<div class=\"mw-userpage-userdoesnotexist error\">\n\$1\n</div>", + [ + 'contributions-userdoesnotexist', + wfEscapeWikiText( $userObj->getName() ), + ] + ); + if ( !$this->including() ) { + $this->getOutput()->setStatusCode( 404 ); + } + } + $user = htmlspecialchars( $userObj->getName() ); + } else { + $user = $this->getLinkRenderer()->makeLink( $userObj->getUserPage(), $userObj->getName() ); + } + $nt = $userObj->getUserPage(); + $talk = $userObj->getTalkPage(); + $links = ''; + if ( $talk ) { + $tools = self::getUserLinks( $this, $userObj ); + $links = $this->getLanguage()->pipeList( $tools ); + + // Show a note if the user is blocked and display the last block log entry. + // Do not expose the autoblocks, since that may lead to a leak of accounts' IPs, + // and also this will display a totally irrelevant log entry as a current block. + if ( !$this->including() ) { + // For IP ranges you must give Block::newFromTarget the CIDR string and not a user object. + if ( $userObj->isIPRange() ) { + $block = Block::newFromTarget( $userObj->getName(), $userObj->getName() ); + } else { + $block = Block::newFromTarget( $userObj, $userObj ); + } + + if ( !is_null( $block ) && $block->getType() != Block::TYPE_AUTO ) { + if ( $block->getType() == Block::TYPE_RANGE ) { + $nt = MWNamespace::getCanonicalName( NS_USER ) . ':' . $block->getTarget(); + } + + $out = $this->getOutput(); // showLogExtract() wants first parameter by reference + LogEventsList::showLogExtract( + $out, + 'block', + $nt, + '', + [ + 'lim' => 1, + 'showIfEmpty' => false, + 'msgKey' => [ + $userObj->isAnon() ? + 'sp-contributions-blocked-notice-anon' : + 'sp-contributions-blocked-notice', + $userObj->getName() # Support GENDER in 'sp-contributions-blocked-notice' + ], + 'offset' => '' # don't use WebRequest parameter offset + ] + ); + } + } + } + + return $this->msg( 'contribsub2' )->rawParams( $user, $links )->params( $userObj->getName() ); + } + + /** + * Links to different places. + * + * @note This function is also called in DeletedContributionsPage + * @param SpecialPage $sp SpecialPage instance, for context + * @param User $target Target user object + * @return array + */ + public static function getUserLinks( SpecialPage $sp, User $target ) { + $id = $target->getId(); + $username = $target->getName(); + $userpage = $target->getUserPage(); + $talkpage = $target->getTalkPage(); + + $linkRenderer = $sp->getLinkRenderer(); + + # No talk pages for IP ranges. + if ( !IP::isValidRange( $username ) ) { + $tools['user-talk'] = $linkRenderer->makeLink( + $talkpage, + $sp->msg( 'sp-contributions-talk' )->text() + ); + } + + if ( ( $id !== null ) || ( $id === null && IP::isIPAddress( $username ) ) ) { + if ( $sp->getUser()->isAllowed( 'block' ) ) { # Block / Change block / Unblock links + if ( $target->isBlocked() && $target->getBlock()->getType() != Block::TYPE_AUTO ) { + $tools['block'] = $linkRenderer->makeKnownLink( # Change block link + SpecialPage::getTitleFor( 'Block', $username ), + $sp->msg( 'change-blocklink' )->text() + ); + $tools['unblock'] = $linkRenderer->makeKnownLink( # Unblock link + SpecialPage::getTitleFor( 'Unblock', $username ), + $sp->msg( 'unblocklink' )->text() + ); + } else { # User is not blocked + $tools['block'] = $linkRenderer->makeKnownLink( # Block link + SpecialPage::getTitleFor( 'Block', $username ), + $sp->msg( 'blocklink' )->text() + ); + } + } + + # Block log link + $tools['log-block'] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Log', 'block' ), + $sp->msg( 'sp-contributions-blocklog' )->text(), + [], + [ 'page' => $userpage->getPrefixedText() ] + ); + + # Suppression log link (T61120) + if ( $sp->getUser()->isAllowed( 'suppressionlog' ) ) { + $tools['log-suppression'] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Log', 'suppress' ), + $sp->msg( 'sp-contributions-suppresslog', $username )->text(), + [], + [ 'offender' => $username ] + ); + } + } + + # Don't show some links for IP ranges + if ( !IP::isValidRange( $username ) ) { + # Uploads + $tools['uploads'] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Listfiles', $username ), + $sp->msg( 'sp-contributions-uploads' )->text() + ); + + # Other logs link + $tools['logs'] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Log', $username ), + $sp->msg( 'sp-contributions-logs' )->text() + ); + + # Add link to deleted user contributions for priviledged users + if ( $sp->getUser()->isAllowed( 'deletedhistory' ) ) { + $tools['deletedcontribs'] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'DeletedContributions', $username ), + $sp->msg( 'sp-contributions-deleted', $username )->text() + ); + } + } + + # Add a link to change user rights for privileged users + $userrightsPage = new UserrightsPage(); + $userrightsPage->setContext( $sp->getContext() ); + if ( $userrightsPage->userCanChangeRights( $target ) ) { + $tools['userrights'] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Userrights', $username ), + $sp->msg( 'sp-contributions-userrights', $username )->text() + ); + } + + Hooks::run( 'ContributionsToolLinks', [ $id, $userpage, &$tools, $sp ] ); + + return $tools; + } + + /** + * Generates the namespace selector form with hidden attributes. + * @return string HTML fragment + */ + protected function getForm() { + $this->opts['title'] = $this->getPageTitle()->getPrefixedText(); + if ( !isset( $this->opts['target'] ) ) { + $this->opts['target'] = ''; + } else { + $this->opts['target'] = str_replace( '_', ' ', $this->opts['target'] ); + } + + if ( !isset( $this->opts['namespace'] ) ) { + $this->opts['namespace'] = ''; + } + + if ( !isset( $this->opts['nsInvert'] ) ) { + $this->opts['nsInvert'] = ''; + } + + if ( !isset( $this->opts['associated'] ) ) { + $this->opts['associated'] = false; + } + + if ( !isset( $this->opts['contribs'] ) ) { + $this->opts['contribs'] = 'user'; + } + + if ( !isset( $this->opts['start'] ) ) { + $this->opts['start'] = ''; + } + + if ( !isset( $this->opts['end'] ) ) { + $this->opts['end'] = ''; + } + + if ( $this->opts['contribs'] == 'newbie' ) { + $this->opts['target'] = ''; + } + + if ( !isset( $this->opts['tagfilter'] ) ) { + $this->opts['tagfilter'] = ''; + } + + if ( !isset( $this->opts['topOnly'] ) ) { + $this->opts['topOnly'] = false; + } + + if ( !isset( $this->opts['newOnly'] ) ) { + $this->opts['newOnly'] = false; + } + + if ( !isset( $this->opts['hideMinor'] ) ) { + $this->opts['hideMinor'] = false; + } + + // Modules required only for the form + $this->getOutput()->addModules( [ + 'mediawiki.userSuggest', + 'mediawiki.special.contributions', + ] ); + $this->getOutput()->addModuleStyles( 'mediawiki.widgets.DateInputWidget.styles' ); + $this->getOutput()->enableOOUI(); + + $form = Html::openElement( + 'form', + [ + 'method' => 'get', + 'action' => wfScript(), + 'class' => 'mw-contributions-form' + ] + ); + + # Add hidden params for tracking except for parameters in $skipParameters + $skipParameters = [ + 'namespace', + 'nsInvert', + 'deletedOnly', + 'target', + 'contribs', + 'year', + 'month', + 'start', + 'end', + 'topOnly', + 'newOnly', + 'hideMinor', + 'associated', + 'tagfilter' + ]; + + foreach ( $this->opts as $name => $value ) { + if ( in_array( $name, $skipParameters ) ) { + continue; + } + $form .= "\t" . Html::hidden( $name, $value ) . "\n"; + } + + $tagFilter = ChangeTags::buildTagFilterSelector( + $this->opts['tagfilter'], false, $this->getContext() ); + + if ( $tagFilter ) { + $filterSelection = Html::rawElement( + 'div', + [], + implode( ' ', $tagFilter ) + ); + } else { + $filterSelection = Html::rawElement( 'div', [], '' ); + } + + $labelNewbies = Xml::radioLabel( + $this->msg( 'sp-contributions-newbies' )->text(), + 'contribs', + 'newbie', + 'newbie', + $this->opts['contribs'] == 'newbie', + [ 'class' => 'mw-input' ] + ); + $labelUsername = Xml::radioLabel( + $this->msg( 'sp-contributions-username' )->text(), + 'contribs', + 'user', + 'user', + $this->opts['contribs'] == 'user', + [ 'class' => 'mw-input' ] + ); + $input = Html::input( + 'target', + $this->opts['target'], + 'text', + [ + 'size' => '40', + 'class' => [ + 'mw-input', + 'mw-ui-input-inline', + 'mw-autocomplete-user', // used by mediawiki.userSuggest + ], + ] + ( + // Only autofocus if target hasn't been specified or in non-newbies mode + ( $this->opts['contribs'] === 'newbie' || $this->opts['target'] ) + ? [] : [ 'autofocus' => true ] + ) + ); + + $targetSelection = Html::rawElement( + 'div', + [], + $labelNewbies . '<br>' . $labelUsername . ' ' . $input . ' ' + ); + + $namespaceSelection = Xml::tags( + 'div', + [], + Xml::label( + $this->msg( 'namespace' )->text(), + 'namespace', + '' + ) . ' ' . + Html::namespaceSelector( + [ 'selected' => $this->opts['namespace'], 'all' => '' ], + [ + 'name' => 'namespace', + 'id' => 'namespace', + 'class' => 'namespaceselector', + ] + ) . ' ' . + Html::rawElement( + 'span', + [ 'class' => 'mw-input-with-label' ], + Xml::checkLabel( + $this->msg( 'invert' )->text(), + 'nsInvert', + 'nsInvert', + $this->opts['nsInvert'], + [ + 'title' => $this->msg( 'tooltip-invert' )->text(), + 'class' => 'mw-input' + ] + ) . ' ' + ) . + Html::rawElement( 'span', [ 'class' => 'mw-input-with-label' ], + Xml::checkLabel( + $this->msg( 'namespace_association' )->text(), + 'associated', + 'associated', + $this->opts['associated'], + [ + 'title' => $this->msg( 'tooltip-namespace_association' )->text(), + 'class' => 'mw-input' + ] + ) . ' ' + ) + ); + + $filters = []; + + if ( $this->getUser()->isAllowed( 'deletedhistory' ) ) { + $filters[] = Html::rawElement( + 'span', + [ 'class' => 'mw-input-with-label' ], + Xml::checkLabel( + $this->msg( 'history-show-deleted' )->text(), + 'deletedOnly', + 'mw-show-deleted-only', + $this->opts['deletedOnly'], + [ 'class' => 'mw-input' ] + ) + ); + } + + $filters[] = Html::rawElement( + 'span', + [ 'class' => 'mw-input-with-label' ], + Xml::checkLabel( + $this->msg( 'sp-contributions-toponly' )->text(), + 'topOnly', + 'mw-show-top-only', + $this->opts['topOnly'], + [ 'class' => 'mw-input' ] + ) + ); + $filters[] = Html::rawElement( + 'span', + [ 'class' => 'mw-input-with-label' ], + Xml::checkLabel( + $this->msg( 'sp-contributions-newonly' )->text(), + 'newOnly', + 'mw-show-new-only', + $this->opts['newOnly'], + [ 'class' => 'mw-input' ] + ) + ); + $filters[] = Html::rawElement( + 'span', + [ 'class' => 'mw-input-with-label' ], + Xml::checkLabel( + $this->msg( 'sp-contributions-hideminor' )->text(), + 'hideMinor', + 'mw-hide-minor-edits', + $this->opts['hideMinor'], + [ 'class' => 'mw-input' ] + ) + ); + + Hooks::run( + 'SpecialContributions::getForm::filters', + [ $this, &$filters ] + ); + + $extraOptions = Html::rawElement( + 'div', + [], + implode( '', $filters ) + ); + + $dateRangeSelection = Html::rawElement( + 'div', + [], + Xml::label( wfMessage( 'date-range-from' )->text(), 'mw-date-start' ) . ' ' . + new DateInputWidget( [ + 'infusable' => true, + 'id' => 'mw-date-start', + 'name' => 'start', + 'value' => $this->opts['start'], + 'longDisplayFormat' => true, + ] ) . '<br>' . + Xml::label( wfMessage( 'date-range-to' )->text(), 'mw-date-end' ) . ' ' . + new DateInputWidget( [ + 'infusable' => true, + 'id' => 'mw-date-end', + 'name' => 'end', + 'value' => $this->opts['end'], + 'longDisplayFormat' => true, + ] ) + ); + + $submit = Xml::tags( 'div', [], + Html::submitButton( + $this->msg( 'sp-contributions-submit' )->text(), + [ 'class' => 'mw-submit' ], [ 'mw-ui-progressive' ] + ) + ); + + $form .= Xml::fieldset( + $this->msg( 'sp-contributions-search' )->text(), + $targetSelection . + $namespaceSelection . + $filterSelection . + $extraOptions . + $dateRangeSelection . + $submit, + [ 'class' => 'mw-contributions-table' ] + ); + + $explain = $this->msg( 'sp-contributions-explain' ); + if ( !$explain->isBlank() ) { + $form .= "<p id='mw-sp-contributions-explain'>{$explain->parse()}</p>"; + } + + $form .= Xml::closeElement( 'form' ); + + return $form; + } + + /** + * 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'; + } +} diff --git a/www/wiki/includes/specials/SpecialCreateAccount.php b/www/wiki/includes/specials/SpecialCreateAccount.php new file mode 100644 index 00000000..73beafce --- /dev/null +++ b/www/wiki/includes/specials/SpecialCreateAccount.php @@ -0,0 +1,173 @@ +<?php +/** + * Implements Special:CreateAccount + * + * 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 + */ + +use MediaWiki\Auth\AuthManager; +use MediaWiki\Logger\LoggerFactory; + +/** + * Implements Special:CreateAccount + * + * @ingroup SpecialPage + */ +class SpecialCreateAccount extends LoginSignupSpecialPage { + protected static $allowedActions = [ + AuthManager::ACTION_CREATE, + AuthManager::ACTION_CREATE_CONTINUE + ]; + + protected static $messages = [ + 'authform-newtoken' => 'nocookiesfornew', + 'authform-notoken' => 'sessionfailure', + 'authform-wrongtoken' => 'sessionfailure', + ]; + + public function __construct() { + parent::__construct( 'CreateAccount' ); + } + + public function doesWrites() { + return true; + } + + public function isRestricted() { + return !User::groupHasPermission( '*', 'createaccount' ); + } + + public function userCanExecute( User $user ) { + return $user->isAllowed( 'createaccount' ); + } + + public function checkPermissions() { + parent::checkPermissions(); + + $user = $this->getUser(); + $status = AuthManager::singleton()->checkAccountCreatePermissions( $user ); + if ( !$status->isGood() ) { + throw new ErrorPageError( 'createacct-error', $status->getMessage() ); + } + } + + protected function getLoginSecurityLevel() { + return false; + } + + protected function getDefaultAction( $subPage ) { + return AuthManager::ACTION_CREATE; + } + + public function getDescription() { + return $this->msg( 'createaccount' )->text(); + } + + protected function isSignup() { + return true; + } + + /** + * Run any hooks registered for logins, then display a message welcoming + * the user. + * @param bool $direct True if the action was successful just now; false if that happened + * pre-redirection (so this handler was called already) + * @param StatusValue|null $extraMessages + */ + protected function successfulAction( $direct = false, $extraMessages = null ) { + $session = $this->getRequest()->getSession(); + $user = $this->targetUser ?: $this->getUser(); + + if ( $direct ) { + # Only save preferences if the user is not creating an account for someone else. + if ( !$this->proxyAccountCreation ) { + Hooks::run( 'AddNewAccount', [ $user, false ] ); + + // If the user does not have a session cookie at this point, they probably need to + // do something to their browser. + if ( !$this->hasSessionCookie() ) { + $this->mainLoginForm( [ /*?*/ ], $session->getProvider()->whyNoSession() ); + // TODO something more specific? This used to use nocookiesnew + // FIXME should redirect to login page instead? + return; + } + } else { + $byEmail = false; // FIXME no way to set this + + Hooks::run( 'AddNewAccount', [ $user, $byEmail ] ); + + $out = $this->getOutput(); + $out->setPageTitle( $this->msg( $byEmail ? 'accmailtitle' : 'accountcreated' ) ); + if ( $byEmail ) { + $out->addWikiMsg( 'accmailtext', $user->getName(), $user->getEmail() ); + } else { + $out->addWikiMsg( 'accountcreatedtext', $user->getName() ); + } + + $rt = Title::newFromText( $this->mReturnTo ); + $out->addReturnTo( + ( $rt && !$rt->isExternal() ) ? $rt : $this->getPageTitle(), + wfCgiToArray( $this->mReturnToQuery ) + ); + return; + } + } + + $this->clearToken(); + + # Run any hooks; display injected HTML + $injected_html = ''; + $welcome_creation_msg = 'welcomecreation-msg'; + Hooks::run( 'UserLoginComplete', [ &$user, &$injected_html, $direct ] ); + + /** + * Let any extensions change what message is shown. + * @see https://www.mediawiki.org/wiki/Manual:Hooks/BeforeWelcomeCreation + * @since 1.18 + */ + Hooks::run( 'BeforeWelcomeCreation', [ &$welcome_creation_msg, &$injected_html ] ); + + $this->showSuccessPage( 'signup', $this->msg( 'welcomeuser', $this->getUser()->getName() ), + $welcome_creation_msg, $injected_html, $extraMessages ); + } + + protected function getToken() { + return $this->getRequest()->getSession()->getToken( '', 'createaccount' ); + } + + protected function clearToken() { + return $this->getRequest()->getSession()->resetToken( 'createaccount' ); + } + + protected function getTokenName() { + return 'wpCreateaccountToken'; + } + + protected function getGroupName() { + return 'login'; + } + + protected function logAuthResult( $success, $status = null ) { + LoggerFactory::getInstance( 'authevents' )->info( 'Account creation attempt', [ + 'event' => 'accountcreation', + 'successful' => $success, + 'status' => $status, + ] ); + } +} diff --git a/www/wiki/includes/specials/SpecialDeadendpages.php b/www/wiki/includes/specials/SpecialDeadendpages.php new file mode 100644 index 00000000..f13f231d --- /dev/null +++ b/www/wiki/includes/specials/SpecialDeadendpages.php @@ -0,0 +1,94 @@ +<?php +/** + * Implements Special:Deadenpages + * + * 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 + */ + +/** + * A special page that list pages that contain no link to other pages + * + * @ingroup SpecialPage + */ +class DeadendPagesPage extends PageQueryPage { + + function __construct( $name = 'Deadendpages' ) { + parent::__construct( $name ); + } + + function getPageHeader() { + return $this->msg( 'deadendpagestext' )->parseAsBlock(); + } + + /** + * LEFT JOIN is expensive + * + * @return bool + */ + function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + /** + * @return bool + */ + function sortDescending() { + return false; + } + + function getQueryInfo() { + return [ + 'tables' => [ 'page', 'pagelinks' ], + 'fields' => [ + 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'page_title' + ], + 'conds' => [ + 'pl_from IS NULL', + 'page_namespace' => MWNamespace::getContentNamespaces(), + 'page_is_redirect' => 0 + ], + 'join_conds' => [ + 'pagelinks' => [ + 'LEFT JOIN', + [ 'page_id=pl_from' ] + ] + ] + ]; + } + + function getOrderFields() { + // For some crazy reason ordering by a constant + // causes a filesort + if ( count( MWNamespace::getContentNamespaces() ) > 1 ) { + return [ 'page_namespace', 'page_title' ]; + } else { + return [ 'page_title' ]; + } + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialDeletedContributions.php b/www/wiki/includes/specials/SpecialDeletedContributions.php new file mode 100644 index 00000000..975d64e3 --- /dev/null +++ b/www/wiki/includes/specials/SpecialDeletedContributions.php @@ -0,0 +1,243 @@ +<?php +/** + * Implements Special:DeletedContributions + * + * 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 + */ + +use MediaWiki\MediaWikiServices; + +/** + * Implements Special:DeletedContributions to display archived revisions + * @ingroup SpecialPage + */ +class DeletedContributionsPage extends SpecialPage { + /** @var FormOptions */ + protected $mOpts; + + function __construct() { + parent::__construct( 'DeletedContributions', 'deletedhistory' ); + } + + /** + * Special page "deleted user contributions". + * Shows a list of the deleted contributions of a user. + * + * @param string $par (optional) user name of the user for which to show the contributions + */ + function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + $this->checkPermissions(); + + $user = $this->getUser(); + + $out = $this->getOutput(); + $out->setPageTitle( $this->msg( 'deletedcontributions-title' ) ); + + $opts = new FormOptions(); + + $opts->add( 'target', '' ); + $opts->add( 'namespace', '' ); + $opts->add( 'limit', 20 ); + + $opts->fetchValuesFromRequest( $this->getRequest() ); + $opts->validateIntBounds( 'limit', 0, $this->getConfig()->get( 'QueryPageDefaultLimit' ) ); + + if ( $par !== null ) { + $opts->setValue( 'target', $par ); + } + + $ns = $opts->getValue( 'namespace' ); + if ( $ns !== null && $ns !== '' ) { + $opts->setValue( 'namespace', intval( $ns ) ); + } + + $this->mOpts = $opts; + + $target = $opts->getValue( 'target' ); + if ( !strlen( $target ) ) { + $this->getForm(); + + return; + } + + $userObj = User::newFromName( $target, false ); + if ( !$userObj ) { + $this->getForm(); + + return; + } + $this->getSkin()->setRelevantUser( $userObj ); + + $target = $userObj->getName(); + $out->addSubtitle( $this->getSubTitle( $userObj ) ); + + $this->getForm(); + + $pager = new DeletedContribsPager( $this->getContext(), $target, $opts->getValue( 'namespace' ) ); + if ( !$pager->getNumRows() ) { + $out->addWikiMsg( 'nocontribs' ); + + return; + } + + # Show a message about replica DB lag, if applicable + $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + $lag = $lb->safeGetLag( $pager->getDatabase() ); + if ( $lag > 0 ) { + $out->showLagWarning( $lag ); + } + + $out->addHTML( + '<p>' . $pager->getNavigationBar() . '</p>' . + $pager->getBody() . + '<p>' . $pager->getNavigationBar() . '</p>' ); + + # If there were contributions, and it was a valid user or IP, show + # the appropriate "footer" message - WHOIS tools, etc. + if ( $target != 'newbies' ) { + $message = IP::isIPAddress( $target ) ? + 'sp-contributions-footer-anon' : + 'sp-contributions-footer'; + + if ( !$this->msg( $message )->isDisabled() ) { + $out->wrapWikiMsg( + "<div class='mw-contributions-footer'>\n$1\n</div>", + [ $message, $target ] + ); + } + } + } + + /** + * Generates the subheading with links + * @param User $userObj User object for the target + * @return string Appropriately-escaped HTML to be output literally + */ + function getSubTitle( $userObj ) { + $linkRenderer = $this->getLinkRenderer(); + if ( $userObj->isAnon() ) { + $user = htmlspecialchars( $userObj->getName() ); + } else { + $user = $linkRenderer->makeLink( $userObj->getUserPage(), $userObj->getName() ); + } + $links = ''; + $nt = $userObj->getUserPage(); + $talk = $nt->getTalkPage(); + if ( $talk ) { + $tools = SpecialContributions::getUserLinks( $this, $userObj ); + + # Link to contributions + $insert['contribs'] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Contributions', $nt->getDBkey() ), + $this->msg( 'sp-deletedcontributions-contribs' )->text() + ); + + // Swap out the deletedcontribs link for our contribs one + $tools = wfArrayInsertAfter( $tools, $insert, 'deletedcontribs' ); + unset( $tools['deletedcontribs'] ); + + $links = $this->getLanguage()->pipeList( $tools ); + + // Show a note if the user is blocked and display the last block log entry. + $block = Block::newFromTarget( $userObj, $userObj ); + if ( !is_null( $block ) && $block->getType() != Block::TYPE_AUTO ) { + if ( $block->getType() == Block::TYPE_RANGE ) { + $nt = MWNamespace::getCanonicalName( NS_USER ) . ':' . $block->getTarget(); + } + + // LogEventsList::showLogExtract() wants the first parameter by ref + $out = $this->getOutput(); + LogEventsList::showLogExtract( + $out, + 'block', + $nt, + '', + [ + 'lim' => 1, + 'showIfEmpty' => false, + 'msgKey' => [ + 'sp-contributions-blocked-notice', + $userObj->getName() # Support GENDER in 'sp-contributions-blocked-notice' + ], + 'offset' => '' # don't use $this->getRequest() parameter offset + ] + ); + } + } + + return $this->msg( 'contribsub2' )->rawParams( $user, $links )->params( $userObj->getName() ); + } + + /** + * Generates the namespace selector form with hidden attributes. + */ + function getForm() { + $opts = $this->mOpts; + + $formDescriptor = [ + 'target' => [ + 'type' => 'user', + 'name' => 'target', + 'label-message' => 'sp-contributions-username', + 'default' => $opts->getValue( 'target' ), + 'ipallowed' => true, + ], + + 'namespace' => [ + 'type' => 'namespaceselect', + 'name' => 'namespace', + 'label-message' => 'namespace', + 'all' => '', + ], + ]; + + HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) + ->setWrapperLegendMsg( 'sp-contributions-search' ) + ->setSubmitTextMsg( 'sp-contributions-submit' ) + // prevent setting subpage and 'target' parameter at the same time + ->setAction( $this->getPageTitle()->getLocalURL() ) + ->setMethod( 'get' ) + ->prepareForm() + ->displayForm( false ); + } + + /** + * 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'; + } +} diff --git a/www/wiki/includes/specials/SpecialDiff.php b/www/wiki/includes/specials/SpecialDiff.php new file mode 100644 index 00000000..b27a8b4d --- /dev/null +++ b/www/wiki/includes/specials/SpecialDiff.php @@ -0,0 +1,119 @@ +<?php +/** + * Redirect from Special:Diff/### to index.php?diff=### and + * from Special:Diff/###/### to index.php?oldid=###&diff=###. + * + * 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 + */ + +/** + * Redirect from Special:Diff/### to index.php?diff=### and + * from Special:Diff/###/### to index.php?oldid=###&diff=###. + * + * All of the following are valid usages: + * - [[Special:Diff/12345]] (diff of a revision with the previous one) + * - [[Special:Diff/12345/prev]] (diff of a revision with the previous one as well) + * - [[Special:Diff/12345/next]] (diff of a revision with the next one) + * - [[Special:Diff/12345/cur]] (diff of a revision with the latest one of that page) + * - [[Special:Diff/12345/98765]] (diff between arbitrary two revisions) + * + * @ingroup SpecialPage + * @since 1.23 + */ +class SpecialDiff extends RedirectSpecialPage { + public function __construct() { + parent::__construct( 'Diff' ); + $this->mAllowedRedirectParams = []; + } + + /** + * @param string|null $subpage + * @return Title|bool + */ + public function getRedirect( $subpage ) { + $parts = explode( '/', $subpage ); + + // Try to parse the values given, generating somewhat pretty URLs if possible + if ( count( $parts ) === 1 && $parts[0] !== '' ) { + $this->mAddedRedirectParams['diff'] = $parts[0]; + } elseif ( count( $parts ) === 2 ) { + $this->mAddedRedirectParams['oldid'] = $parts[0]; + $this->mAddedRedirectParams['diff'] = $parts[1]; + } else { + return false; + } + + return true; + } + + protected function showNoRedirectPage() { + $this->addHelpLink( 'Help:Diff' ); + $this->setHeaders(); + $this->outputHeader(); + $this->showForm(); + } + + private function showForm() { + $form = HTMLForm::factory( 'ooui', [ + 'oldid' => [ + 'name' => 'oldid', + 'type' => 'int', + 'label-message' => 'diff-form-oldid', + ], + 'diff' => [ + 'name' => 'diff', + 'class' => HTMLTextField::class, + 'label-message' => 'diff-form-revid', + ], + ], $this->getContext(), 'diff-form' ); + $form->setSubmitTextMsg( 'diff-form-submit' ); + $form->setSubmitCallback( [ $this, 'onFormSubmit' ] ); + $form->show(); + } + + public function onFormSubmit( $formData ) { + $params = []; + if ( $formData['oldid'] ) { + $params[] = $formData['oldid']; + } + if ( $formData['diff'] ) { + $params[] = $formData['diff']; + } + $title = $this->getPageTitle( $params ? implode( '/', $params ) : null ); + $url = $title->getFullUrlForRedirect(); + $this->getOutput()->redirect( $url ); + } + + public function getDescription() { + // 'diff' message is in lowercase, using own message + return $this->msg( 'diff-form' )->text(); + } + + public function getName() { + return 'diff-form'; + } + + public function isListed() { + return true; + } + + protected function getGroupName() { + return 'redirects'; + } +} diff --git a/www/wiki/includes/specials/SpecialDoubleRedirects.php b/www/wiki/includes/specials/SpecialDoubleRedirects.php new file mode 100644 index 00000000..77c59f03 --- /dev/null +++ b/www/wiki/includes/specials/SpecialDoubleRedirects.php @@ -0,0 +1,233 @@ +<?php +/** + * Implements Special:DoubleRedirects + * + * 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 + */ + +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * A special page listing redirects to redirecting page. + * The software will automatically not follow double redirects, to prevent loops. + * + * @ingroup SpecialPage + */ +class DoubleRedirectsPage extends QueryPage { + function __construct( $name = 'DoubleRedirects' ) { + parent::__construct( $name ); + } + + public function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + function sortDescending() { + return false; + } + + function getPageHeader() { + return $this->msg( 'doubleredirectstext' )->parseAsBlock(); + } + + function reallyGetQueryInfo( $namespace = null, $title = null ) { + $limitToTitle = !( $namespace === null && $title === null ); + $dbr = wfGetDB( DB_REPLICA ); + $retval = [ + 'tables' => [ + 'ra' => 'redirect', + 'rb' => 'redirect', + 'pa' => 'page', + 'pb' => 'page' + ], + 'fields' => [ + 'namespace' => 'pa.page_namespace', + 'title' => 'pa.page_title', + 'value' => 'pa.page_title', + + 'b_namespace' => 'pb.page_namespace', + 'b_title' => 'pb.page_title', + + // Select fields from redirect instead of page. Because there may + // not actually be a page table row for this target (e.g. for interwiki redirects) + 'c_namespace' => 'rb.rd_namespace', + 'c_title' => 'rb.rd_title', + 'c_fragment' => 'rb.rd_fragment', + 'c_interwiki' => 'rb.rd_interwiki', + ], + 'conds' => [ + 'ra.rd_from = pa.page_id', + + // Filter out redirects where the target goes interwiki (T42353). + // This isn't an optimization, it is required for correct results, + // otherwise a non-double redirect like Bar -> w:Foo will show up + // like "Bar -> Foo -> w:Foo". + + // Need to check both NULL and "" for some reason, + // apparently either can be stored for non-iw entries. + 'ra.rd_interwiki IS NULL OR ra.rd_interwiki = ' . $dbr->addQuotes( '' ), + + 'pb.page_namespace = ra.rd_namespace', + 'pb.page_title = ra.rd_title', + + 'rb.rd_from = pb.page_id', + ] + ]; + + if ( $limitToTitle ) { + $retval['conds']['pa.page_namespace'] = $namespace; + $retval['conds']['pa.page_title'] = $title; + } + + return $retval; + } + + public function getQueryInfo() { + return $this->reallyGetQueryInfo(); + } + + function getOrderFields() { + return [ 'ra.rd_namespace', 'ra.rd_title' ]; + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + // If no Title B or C is in the query, it means this came from + // querycache (which only saves the 3 columns for title A). + // That does save the bulk of the query cost, but now we need to + // get a little more detail about each individual entry quickly + // using the filter of reallyGetQueryInfo. + $deep = false; + if ( $result ) { + if ( isset( $result->b_namespace ) ) { + $deep = $result; + } else { + $dbr = wfGetDB( DB_REPLICA ); + $qi = $this->reallyGetQueryInfo( + $result->namespace, + $result->title + ); + $res = $dbr->select( + $qi['tables'], + $qi['fields'], + $qi['conds'], + __METHOD__ + ); + + if ( $res ) { + $deep = $dbr->fetchObject( $res ) ?: false; + } + } + } + + $titleA = Title::makeTitle( $result->namespace, $result->title ); + + $linkRenderer = $this->getLinkRenderer(); + if ( !$deep ) { + return '<del>' . $linkRenderer->makeLink( $titleA, null, [], [ 'redirect' => 'no' ] ) . '</del>'; + } + + // 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( $titleA )->supportsDirectEditing() + ) { + $edit = $linkRenderer->makeKnownLink( + $titleA, + $this->msg( 'parentheses', $this->msg( 'editlink' )->text() )->text(), + [], + [ 'action' => 'edit' ] + ); + } else { + $edit = ''; + } + + $linkA = $linkRenderer->makeKnownLink( + $titleA, + null, + [], + [ 'redirect' => 'no' ] + ); + + $titleB = Title::makeTitle( $deep->b_namespace, $deep->b_title ); + $linkB = $linkRenderer->makeKnownLink( + $titleB, + null, + [], + [ 'redirect' => 'no' ] + ); + + $titleC = Title::makeTitle( + $deep->c_namespace, + $deep->c_title, + $deep->c_fragment, + $deep->c_interwiki + ); + $linkC = $linkRenderer->makeKnownLink( $titleC, $titleC->getFullText() ); + + $lang = $this->getLanguage(); + $arr = $lang->getArrow() . $lang->getDirMark(); + + return ( "{$linkA} {$edit} {$arr} {$linkB} {$arr} {$linkC}" ); + } + + /** + * Cache page content model and gender distinction for performance + * + * @param IDatabase $db + * @param IResultWrapper $res + */ + function preprocessResults( $db, $res ) { + if ( !$res->numRows() ) { + return; + } + + $batch = new LinkBatch; + foreach ( $res as $row ) { + $batch->add( $row->namespace, $row->title ); + if ( isset( $row->b_namespace ) ) { + // lazy loaded when using cached results + $batch->add( $row->b_namespace, $row->b_title ); + } + if ( isset( $row->c_interwiki ) && !$row->c_interwiki ) { + // lazy loaded when using cached result, not added when interwiki link + $batch->add( $row->c_namespace, $row->c_title ); + } + } + $batch->execute(); + + // Back to start for display + $res->seek( 0 ); + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialEditTags.php b/www/wiki/includes/specials/SpecialEditTags.php new file mode 100644 index 00000000..d11cf64c --- /dev/null +++ b/www/wiki/includes/specials/SpecialEditTags.php @@ -0,0 +1,473 @@ +<?php +/** + * 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 + */ + +/** + * Special page for adding and removing change tags to individual revisions. + * A lot of this is copied out of SpecialRevisiondelete. + * + * @ingroup SpecialPage + * @since 1.25 + */ +class SpecialEditTags extends UnlistedSpecialPage { + /** @var bool Was the DB modified in this request */ + protected $wasSaved = false; + + /** @var bool True if the submit button was clicked, and the form was posted */ + private $submitClicked; + + /** @var array Target ID list */ + private $ids; + + /** @var Title Title object for target parameter */ + private $targetObj; + + /** @var string Deletion type, may be revision or logentry */ + private $typeName; + + /** @var ChangeTagsList Storing the list of items to be tagged */ + private $revList; + + /** @var bool Whether user is allowed to perform the action */ + private $isAllowed; + + /** @var string */ + private $reason; + + public function __construct() { + parent::__construct( 'EditTags', 'changetags' ); + } + + public function doesWrites() { + return true; + } + + public function execute( $par ) { + $this->checkPermissions(); + $this->checkReadOnly(); + + $output = $this->getOutput(); + $user = $this->getUser(); + $request = $this->getRequest(); + + // Check blocks + if ( $user->isBlocked() ) { + throw new UserBlockedError( $user->getBlock() ); + } + + $this->setHeaders(); + $this->outputHeader(); + + $this->getOutput()->addModules( [ 'mediawiki.special.edittags', + 'mediawiki.special.edittags.styles' ] ); + + $this->submitClicked = $request->wasPosted() && $request->getBool( 'wpSubmit' ); + + // Handle our many different possible input types + $ids = $request->getVal( 'ids' ); + if ( !is_null( $ids ) ) { + // Allow CSV from the form hidden field, 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_unique( array_filter( $this->ids ) ); + + // No targets? + if ( count( $this->ids ) == 0 ) { + throw new ErrorPageError( 'tags-edit-nooldid-title', 'tags-edit-nooldid-text' ); + } + + $this->typeName = $request->getVal( 'type' ); + $this->targetObj = Title::newFromText( $request->getText( 'target' ) ); + + // sanity check of parameter + switch ( $this->typeName ) { + case 'logentry': + case 'logging': + $this->typeName = 'logentry'; + break; + default: + $this->typeName = 'revision'; + break; + } + + // Allow the list type to adjust the passed target + // Yuck! Copied straight out of SpecialRevisiondelete, but it does exactly + // what we want + $this->targetObj = RevisionDeleter::suggestTarget( + $this->typeName === 'revision' ? 'revision' : 'logging', + $this->targetObj, + $this->ids + ); + + $this->isAllowed = $user->isAllowed( 'changetags' ); + + $this->reason = $request->getVal( 'wpReason' ); + // We need a target page! + if ( is_null( $this->targetObj ) ) { + $output->addWikiMsg( 'undelete-header' ); + return; + } + // Give a link to the logs/hist for this page + $this->showConvenienceLinks(); + + // Either submit or create our form + if ( $this->isAllowed && $this->submitClicked ) { + $this->submit(); + } else { + $this->showForm(); + } + + // Show relevant lines from the tag log + $tagLogPage = new LogPage( 'tag' ); + $output->addHTML( "<h2>" . $tagLogPage->getName()->escaped() . "</h2>\n" ); + LogEventsList::showLogExtract( + $output, + 'tag', + $this->targetObj, + '', /* user */ + [ 'lim' => 25, 'conds' => [], 'useMaster' => $this->wasSaved ] + ); + } + + /** + * Show some useful links in the subtitle + */ + protected function showConvenienceLinks() { + // 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 ); + + $linkRenderer = $this->getLinkRenderer(); + $links = []; + $links[] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Log' ), + $this->msg( 'viewpagelogs' )->text(), + [], + [ + 'page' => $this->targetObj->getPrefixedText(), + 'hide_tag_log' => '0', + ] + ); + if ( !$this->targetObj->isSpecialPage() ) { + // Give a link to the page history + $links[] = $linkRenderer->makeKnownLink( + $this->targetObj, + $this->msg( 'pagehist' )->text(), + [], + [ 'action' => 'history' ] + ); + } + // Link to Special:Tags + $links[] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Tags' ), + $this->msg( 'tags-edit-manage-link' )->text() + ); + // Logs themselves don't have histories or archived revisions + $this->getOutput()->addSubtitle( $this->getLanguage()->pipeList( $links ) ); + } + } + + /** + * Get the list object for this request + * @return ChangeTagsList + */ + protected function getList() { + if ( is_null( $this->revList ) ) { + $this->revList = ChangeTagsList::factory( $this->typeName, $this->getContext(), + $this->targetObj, $this->ids ); + } + + return $this->revList; + } + + /** + * Show a list of items that we will operate on, and show a form which allows + * the user to modify the tags applied to those items. + */ + protected function showForm() { + $userAllowed = true; + + $out = $this->getOutput(); + // Messages: tags-edit-revision-selected, tags-edit-logentry-selected + $out->wrapWikiMsg( "<strong>$1</strong>", [ + "tags-edit-{$this->typeName}-selected", + $this->getLanguage()->formatNum( count( $this->ids ) ), + $this->targetObj->getPrefixedText() + ] ); + + $this->addHelpLink( 'Help:Tags' ); + $out->addHTML( "<ul>" ); + + $numRevisions = 0; + // Live revisions... + $list = $this->getList(); + // phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall + for ( $list->reset(); $list->current(); $list->next() ) { + $item = $list->current(); + if ( !$item->canView() ) { + throw new ErrorPageError( 'permissionserrors', 'tags-update-no-permission' ); + } + $numRevisions++; + $out->addHTML( $item->getHTML() ); + } + + if ( !$numRevisions ) { + throw new ErrorPageError( 'tags-edit-nooldid-title', 'tags-edit-nooldid-text' ); + } + + $out->addHTML( "</ul>" ); + // Explanation text + $out->wrapWikiMsg( '<p>$1</p>', "tags-edit-{$this->typeName}-explanation" ); + + // Show form if the user can submit + if ( $this->isAllowed ) { + $conf = $this->getConfig(); + $oldCommentSchema = $conf->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD; + + $form = Xml::openElement( 'form', [ 'method' => 'post', + 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ), + 'id' => 'mw-revdel-form-revisions' ] ) . + Xml::fieldset( $this->msg( "tags-edit-{$this->typeName}-legend", + count( $this->ids ) )->text() ) . + $this->buildCheckBoxes() . + Xml::openElement( 'table' ) . + "<tr>\n" . + '<td class="mw-label">' . + Xml::label( $this->msg( 'tags-edit-reason' )->text(), 'wpReason' ) . + '</td>' . + '<td class="mw-input">' . + Xml::input( 'wpReason', 60, $this->reason, [ + '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 (or 255 UTF-8 bytes for old schema). + // "- 155" is to leave room for the auto-generated part of the log entry. + 'maxlength' => $oldCommentSchema ? 100 : CommentStore::COMMENT_CHARACTER_LIMIT - 155, + ] ) . + '</td>' . + "</tr><tr>\n" . + '<td></td>' . + '<td class="mw-submit">' . + Xml::submitButton( $this->msg( "tags-edit-{$this->typeName}-submit", + $numRevisions )->text(), [ 'name' => 'wpSubmit' ] ) . + '</td>' . + "</tr>\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"; + } else { + $form = ''; + } + $out->addHTML( $form ); + } + + /** + * @return string HTML + */ + protected function buildCheckBoxes() { + // If there is just one item, provide the user with a multi-select field + $list = $this->getList(); + $tags = []; + if ( $list->length() == 1 ) { + $list->reset(); + $tags = $list->current()->getTags(); + if ( $tags ) { + $tags = explode( ',', $tags ); + } else { + $tags = []; + } + + $html = '<table id="mw-edittags-tags-selector">'; + $html .= '<tr><td>' . $this->msg( 'tags-edit-existing-tags' )->escaped() . + '</td><td>'; + if ( $tags ) { + $html .= $this->getLanguage()->commaList( array_map( 'htmlspecialchars', $tags ) ); + } else { + $html .= $this->msg( 'tags-edit-existing-tags-none' )->parse(); + } + $html .= '</td></tr>'; + $tagSelect = $this->getTagSelect( $tags, $this->msg( 'tags-edit-new-tags' )->plain() ); + $html .= '<tr><td>' . $tagSelect[0] . '</td><td>' . $tagSelect[1]; + } else { + // Otherwise, use a multi-select field for adding tags, and a list of + // checkboxes for removing them + + // phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall + for ( $list->reset(); $list->current(); $list->next() ) { + $currentTags = $list->current()->getTags(); + if ( $currentTags ) { + $tags = array_merge( $tags, explode( ',', $currentTags ) ); + } + } + $tags = array_unique( $tags ); + + $html = '<table id="mw-edittags-tags-selector-multi"><tr><td>'; + $tagSelect = $this->getTagSelect( [], $this->msg( 'tags-edit-add' )->plain() ); + $html .= '<p>' . $tagSelect[0] . '</p>' . $tagSelect[1] . '</td><td>'; + $html .= Xml::element( 'p', null, $this->msg( 'tags-edit-remove' )->plain() ); + $html .= Xml::checkLabel( $this->msg( 'tags-edit-remove-all-tags' )->plain(), + 'wpRemoveAllTags', 'mw-edittags-remove-all' ); + $i = 0; // used for generating checkbox IDs only + foreach ( $tags as $tag ) { + $html .= Xml::element( 'br' ) . "\n" . Xml::checkLabel( $tag, + 'wpTagsToRemove[]', 'mw-edittags-remove-' . $i++, false, [ + 'value' => $tag, + 'class' => 'mw-edittags-remove-checkbox', + ] ); + } + } + + // also output the tags currently applied as a hidden form field, so we + // know what to remove from the revision/log entry when the form is submitted + $html .= Html::hidden( 'wpExistingTags', implode( ',', $tags ) ); + $html .= '</td></tr></table>'; + + return $html; + } + + /** + * Returns a <select multiple> element with a list of change tags that can be + * applied by users. + * + * @param array $selectedTags The tags that should be preselected in the + * list. Any tags in this list, but not in the list returned by + * ChangeTags::listExplicitlyDefinedTags, will be appended to the <select> + * element. + * @param string $label The text of a <label> to precede the <select> + * @return array HTML <label> element at index 0, HTML <select> element at + * index 1 + */ + protected function getTagSelect( $selectedTags, $label ) { + $result = []; + $result[0] = Xml::label( $label, 'mw-edittags-tag-list' ); + + $select = new XmlSelect( 'wpTagList[]', 'mw-edittags-tag-list', $selectedTags ); + $select->setAttribute( 'multiple', 'multiple' ); + $select->setAttribute( 'size', '8' ); + + $tags = ChangeTags::listExplicitlyDefinedTags(); + $tags = array_unique( array_merge( $tags, $selectedTags ) ); + + // Values of $tags are also used as <option> labels + $select->addOptions( array_combine( $tags, $tags ) ); + + $result[1] = $select->getHTML(); + return $result; + } + + /** + * UI entry point for form submission. + * @throws PermissionsError + * @return bool + */ + protected function submit() { + // Check edit token on submission + $request = $this->getRequest(); + $token = $request->getVal( 'wpEditToken' ); + if ( $this->submitClicked && !$this->getUser()->matchEditToken( $token ) ) { + $this->getOutput()->addWikiMsg( 'sessionfailure' ); + return false; + } + + // Evaluate incoming request data + $tagList = $request->getArray( 'wpTagList' ); + if ( is_null( $tagList ) ) { + $tagList = []; + } + $existingTags = $request->getVal( 'wpExistingTags' ); + if ( is_null( $existingTags ) || $existingTags === '' ) { + $existingTags = []; + } else { + $existingTags = explode( ',', $existingTags ); + } + + if ( count( $this->ids ) > 1 ) { + // multiple revisions selected + $tagsToAdd = $tagList; + if ( $request->getBool( 'wpRemoveAllTags' ) ) { + $tagsToRemove = $existingTags; + } else { + $tagsToRemove = $request->getArray( 'wpTagsToRemove' ); + } + } else { + // single revision selected + // The user tells us which tags they want associated to the revision. + // We have to figure out which ones to add, and which to remove. + $tagsToAdd = array_diff( $tagList, $existingTags ); + $tagsToRemove = array_diff( $existingTags, $tagList ); + } + + if ( !$tagsToAdd && !$tagsToRemove ) { + $status = Status::newFatal( 'tags-edit-none-selected' ); + } else { + $status = $this->getList()->updateChangeTagsOnAll( $tagsToAdd, + $tagsToRemove, null, $this->reason, $this->getUser() ); + } + + if ( $status->isGood() ) { + $this->success(); + return true; + } else { + $this->failure( $status ); + return false; + } + } + + /** + * Report that the submit operation succeeded + */ + protected function success() { + $this->getOutput()->setPageTitle( $this->msg( 'actioncomplete' ) ); + $this->getOutput()->wrapWikiMsg( "<div class=\"successbox\">\n$1\n</div>", + 'tags-edit-success' ); + $this->wasSaved = true; + $this->revList->reloadFromMaster(); + $this->reason = ''; // no need to spew the reason back at the user + $this->showForm(); + } + + /** + * Report that the submit operation failed + * @param Status $status + */ + protected function failure( $status ) { + $this->getOutput()->setPageTitle( $this->msg( 'actionfailed' ) ); + $this->getOutput()->addWikiText( + Html::errorBox( $status->getWikiText( 'tags-edit-failure' ) ) + ); + $this->showForm(); + } + + public function getDescription() { + return $this->msg( 'tags-edit-title' )->text(); + } + + protected function getGroupName() { + return 'pagetools'; + } +} diff --git a/www/wiki/includes/specials/SpecialEditWatchlist.php b/www/wiki/includes/specials/SpecialEditWatchlist.php new file mode 100644 index 00000000..f702bc0b --- /dev/null +++ b/www/wiki/includes/specials/SpecialEditWatchlist.php @@ -0,0 +1,767 @@ +<?php +/** + * @defgroup Watchlist Users watchlist handling + */ + +/** + * Implements Special:EditWatchlist + * + * 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 Watchlist + */ + +use MediaWiki\Linker\LinkRenderer; +use MediaWiki\Linker\LinkTarget; +use MediaWiki\MediaWikiServices; + +/** + * Provides the UI through which users can perform editing + * operations on their watchlist + * + * @ingroup SpecialPage + * @ingroup Watchlist + * @author Rob Church <robchur@gmail.com> + */ +class SpecialEditWatchlist extends UnlistedSpecialPage { + /** + * Editing modes. EDIT_CLEAR is no longer used; the "Clear" link scared people + * too much. Now it's passed on to the raw editor, from which it's very easy to clear. + */ + const EDIT_CLEAR = 1; + const EDIT_RAW = 2; + const EDIT_NORMAL = 3; + + protected $successMessage; + + protected $toc; + + private $badItems = []; + + /** + * @var TitleParser + */ + private $titleParser; + + public function __construct() { + parent::__construct( 'EditWatchlist', 'editmywatchlist' ); + } + + /** + * Initialize any services we'll need (unless it has already been provided via a setter). + * This allows for dependency injection even though we don't control object creation. + */ + private function initServices() { + if ( !$this->titleParser ) { + $this->titleParser = MediaWikiServices::getInstance()->getTitleParser(); + } + } + + public function doesWrites() { + return true; + } + + /** + * Main execution point + * + * @param int $mode + */ + public function execute( $mode ) { + $this->initServices(); + $this->setHeaders(); + + # Anons don't get a watchlist + $this->requireLogin( 'watchlistanontext' ); + + $out = $this->getOutput(); + + $this->checkPermissions(); + $this->checkReadOnly(); + + $this->outputHeader(); + $this->outputSubtitle(); + $out->addModuleStyles( 'mediawiki.special' ); + + # B/C: $mode used to be waaay down the parameter list, and the first parameter + # was $wgUser + if ( $mode instanceof User ) { + $args = func_get_args(); + if ( count( $args ) >= 4 ) { + $mode = $args[3]; + } + } + $mode = self::getMode( $this->getRequest(), $mode ); + + switch ( $mode ) { + case self::EDIT_RAW: + $out->setPageTitle( $this->msg( 'watchlistedit-raw-title' ) ); + $form = $this->getRawForm(); + if ( $form->show() ) { + $out->addHTML( $this->successMessage ); + $out->addReturnTo( SpecialPage::getTitleFor( 'Watchlist' ) ); + } + break; + case self::EDIT_CLEAR: + $out->setPageTitle( $this->msg( 'watchlistedit-clear-title' ) ); + $form = $this->getClearForm(); + if ( $form->show() ) { + $out->addHTML( $this->successMessage ); + $out->addReturnTo( SpecialPage::getTitleFor( 'Watchlist' ) ); + } + break; + + case self::EDIT_NORMAL: + default: + $this->executeViewEditWatchlist(); + break; + } + } + + /** + * Renders a subheader on the watchlist page. + */ + protected function outputSubtitle() { + $out = $this->getOutput(); + $out->addSubtitle( $this->msg( 'watchlistfor2', $this->getUser()->getName() ) + ->rawParams( + self::buildTools( + $this->getLanguage(), + $this->getLinkRenderer() + ) + ) + ); + } + + /** + * Executes an edit mode for the watchlist view, from which you can manage your watchlist + */ + protected function executeViewEditWatchlist() { + $out = $this->getOutput(); + $out->setPageTitle( $this->msg( 'watchlistedit-normal-title' ) ); + $form = $this->getNormalForm(); + if ( $form->show() ) { + $out->addHTML( $this->successMessage ); + $out->addReturnTo( SpecialPage::getTitleFor( 'Watchlist' ) ); + } elseif ( $this->toc !== false ) { + $out->prependHTML( $this->toc ); + $out->addModules( 'mediawiki.toc' ); + } + } + + /** + * Return an array of subpages that this special page will accept. + * + * @see also SpecialWatchlist::getSubpagesForPrefixSearch + * @return string[] subpages + */ + public function getSubpagesForPrefixSearch() { + // SpecialWatchlist uses SpecialEditWatchlist::getMode, so new types should be added + // here and there - no 'edit' here, because that the default for this page + return [ + 'clear', + 'raw', + ]; + } + + /** + * Extract a list of titles from a blob of text, returning + * (prefixed) strings; unwatchable titles are ignored + * + * @param string $list + * @return array + */ + private function extractTitles( $list ) { + $list = explode( "\n", trim( $list ) ); + if ( !is_array( $list ) ) { + return []; + } + + $titles = []; + + foreach ( $list as $text ) { + $text = trim( $text ); + if ( strlen( $text ) > 0 ) { + $title = Title::newFromText( $text ); + if ( $title instanceof Title && $title->isWatchable() ) { + $titles[] = $title; + } + } + } + + MediaWikiServices::getInstance()->getGenderCache()->doTitlesArray( $titles ); + + $list = []; + /** @var Title $title */ + foreach ( $titles as $title ) { + $list[] = $title->getPrefixedText(); + } + + return array_unique( $list ); + } + + public function submitRaw( $data ) { + $wanted = $this->extractTitles( $data['Titles'] ); + $current = $this->getWatchlist(); + + if ( count( $wanted ) > 0 ) { + $toWatch = array_diff( $wanted, $current ); + $toUnwatch = array_diff( $current, $wanted ); + $this->watchTitles( $toWatch ); + $this->unwatchTitles( $toUnwatch ); + $this->getUser()->invalidateCache(); + + if ( count( $toWatch ) > 0 || count( $toUnwatch ) > 0 ) { + $this->successMessage = $this->msg( 'watchlistedit-raw-done' )->parse(); + } else { + return false; + } + + if ( count( $toWatch ) > 0 ) { + $this->successMessage .= ' ' . $this->msg( 'watchlistedit-raw-added' ) + ->numParams( count( $toWatch ) )->parse(); + $this->showTitles( $toWatch, $this->successMessage ); + } + + if ( count( $toUnwatch ) > 0 ) { + $this->successMessage .= ' ' . $this->msg( 'watchlistedit-raw-removed' ) + ->numParams( count( $toUnwatch ) )->parse(); + $this->showTitles( $toUnwatch, $this->successMessage ); + } + } else { + + if ( count( $current ) === 0 ) { + return false; + } + + $this->clearUserWatchedItems( $current, 'raw' ); + $this->showTitles( $current, $this->successMessage ); + } + + return true; + } + + public function submitClear( $data ) { + $current = $this->getWatchlist(); + $this->clearUserWatchedItems( $current, 'clear' ); + $this->showTitles( $current, $this->successMessage ); + return true; + } + + /** + * @param array $current + * @param string $messageFor 'raw' or 'clear' + */ + private function clearUserWatchedItems( $current, $messageFor ) { + $watchedItemStore = MediaWikiServices::getInstance()->getWatchedItemStore(); + if ( $watchedItemStore->clearUserWatchedItems( $this->getUser() ) ) { + $this->successMessage = $this->msg( 'watchlistedit-' . $messageFor . '-done' )->parse(); + $this->successMessage .= ' ' . $this->msg( 'watchlistedit-' . $messageFor . '-removed' ) + ->numParams( count( $current ) )->parse(); + $this->getUser()->invalidateCache(); + } else { + $watchedItemStore->clearUserWatchedItemsUsingJobQueue( $this->getUser() ); + $this->successMessage = $this->msg( 'watchlistedit-clear-jobqueue' )->parse(); + } + } + + /** + * Print out a list of linked titles + * + * $titles can be an array of strings or Title objects; the former + * is preferred, since Titles are very memory-heavy + * + * @param array $titles Array of strings, or Title objects + * @param string $output + */ + private function showTitles( $titles, &$output ) { + $talk = $this->msg( 'talkpagelinktext' )->text(); + // Do a batch existence check + $batch = new LinkBatch(); + if ( count( $titles ) >= 100 ) { + $output = $this->msg( 'watchlistedit-too-many' )->parse(); + return; + } + foreach ( $titles as $title ) { + if ( !$title instanceof Title ) { + $title = Title::newFromText( $title ); + } + + if ( $title instanceof Title ) { + $batch->addObj( $title ); + $batch->addObj( $title->getTalkPage() ); + } + } + + $batch->execute(); + + // Print out the list + $output .= "<ul>\n"; + + $linkRenderer = $this->getLinkRenderer(); + foreach ( $titles as $title ) { + if ( !$title instanceof Title ) { + $title = Title::newFromText( $title ); + } + + if ( $title instanceof Title ) { + $output .= '<li>' . + $linkRenderer->makeLink( $title ) . ' ' . + $this->msg( 'parentheses' )->rawParams( + $linkRenderer->makeLink( $title->getTalkPage(), $talk ) + )->escaped() . + "</li>\n"; + } + } + + $output .= "</ul>\n"; + } + + /** + * Prepare a list of titles on a user's watchlist (excluding talk pages) + * and return an array of (prefixed) strings + * + * @return array + */ + private function getWatchlist() { + $list = []; + + $watchedItems = MediaWikiServices::getInstance()->getWatchedItemStore()->getWatchedItemsForUser( + $this->getUser(), + [ 'forWrite' => $this->getRequest()->wasPosted() ] + ); + + if ( $watchedItems ) { + /** @var Title[] $titles */ + $titles = []; + foreach ( $watchedItems as $watchedItem ) { + $namespace = $watchedItem->getLinkTarget()->getNamespace(); + $dbKey = $watchedItem->getLinkTarget()->getDBkey(); + $title = Title::makeTitleSafe( $namespace, $dbKey ); + + if ( $this->checkTitle( $title, $namespace, $dbKey ) + && !$title->isTalkPage() + ) { + $titles[] = $title; + } + } + + MediaWikiServices::getInstance()->getGenderCache()->doTitlesArray( $titles ); + + foreach ( $titles as $title ) { + $list[] = $title->getPrefixedText(); + } + } + + $this->cleanupWatchlist(); + + return $list; + } + + /** + * Get a list of titles on a user's watchlist, excluding talk pages, + * and return as a two-dimensional array with namespace and title. + * + * @return array + */ + protected function getWatchlistInfo() { + $titles = []; + + $watchedItems = MediaWikiServices::getInstance()->getWatchedItemStore() + ->getWatchedItemsForUser( $this->getUser(), [ 'sort' => WatchedItemStore::SORT_ASC ] ); + + $lb = new LinkBatch(); + + foreach ( $watchedItems as $watchedItem ) { + $namespace = $watchedItem->getLinkTarget()->getNamespace(); + $dbKey = $watchedItem->getLinkTarget()->getDBkey(); + $lb->add( $namespace, $dbKey ); + if ( !MWNamespace::isTalk( $namespace ) ) { + $titles[$namespace][$dbKey] = 1; + } + } + + $lb->execute(); + + return $titles; + } + + /** + * Validates watchlist entry + * + * @param Title $title + * @param int $namespace + * @param string $dbKey + * @return bool Whether this item is valid + */ + private function checkTitle( $title, $namespace, $dbKey ) { + if ( $title + && ( $title->isExternal() + || $title->getNamespace() < 0 + ) + ) { + $title = false; // unrecoverable + } + + if ( !$title + || $title->getNamespace() != $namespace + || $title->getDBkey() != $dbKey + ) { + $this->badItems[] = [ $title, $namespace, $dbKey ]; + } + + return (bool)$title; + } + + /** + * Attempts to clean up broken items + */ + private function cleanupWatchlist() { + if ( !count( $this->badItems ) ) { + return; // nothing to do + } + + $user = $this->getUser(); + $badItems = $this->badItems; + DeferredUpdates::addCallableUpdate( function () use ( $user, $badItems ) { + $store = MediaWikiServices::getInstance()->getWatchedItemStore(); + foreach ( $badItems as $row ) { + list( $title, $namespace, $dbKey ) = $row; + $action = $title ? 'cleaning up' : 'deleting'; + wfDebug( "User {$user->getName()} has broken watchlist item " . + "ns($namespace):$dbKey, $action.\n" ); + + $store->removeWatch( $user, new TitleValue( (int)$namespace, $dbKey ) ); + // Can't just do an UPDATE instead of DELETE/INSERT due to unique index + if ( $title ) { + $user->addWatch( $title ); + } + } + } ); + } + + /** + * Add a list of targets to a user's watchlist + * + * @param string[]|LinkTarget[] $targets + */ + private function watchTitles( $targets ) { + $expandedTargets = []; + foreach ( $targets as $target ) { + if ( !$target instanceof LinkTarget ) { + try { + $target = $this->titleParser->parseTitle( $target, NS_MAIN ); + } + catch ( MalformedTitleException $e ) { + continue; + } + } + + $ns = $target->getNamespace(); + $dbKey = $target->getDBkey(); + $expandedTargets[] = new TitleValue( MWNamespace::getSubject( $ns ), $dbKey ); + $expandedTargets[] = new TitleValue( MWNamespace::getTalk( $ns ), $dbKey ); + } + + MediaWikiServices::getInstance()->getWatchedItemStore()->addWatchBatchForUser( + $this->getUser(), + $expandedTargets + ); + } + + /** + * Remove a list of titles from a user's watchlist + * + * $titles can be an array of strings or Title objects; the former + * is preferred, since Titles are very memory-heavy + * + * @param array $titles Array of strings, or Title objects + */ + private function unwatchTitles( $titles ) { + $store = MediaWikiServices::getInstance()->getWatchedItemStore(); + + foreach ( $titles as $title ) { + if ( !$title instanceof Title ) { + $title = Title::newFromText( $title ); + } + + if ( $title instanceof Title ) { + $store->removeWatch( $this->getUser(), $title->getSubjectPage() ); + $store->removeWatch( $this->getUser(), $title->getTalkPage() ); + + $page = WikiPage::factory( $title ); + Hooks::run( 'UnwatchArticleComplete', [ $this->getUser(), &$page ] ); + } + } + } + + public function submitNormal( $data ) { + $removed = []; + + foreach ( $data as $titles ) { + $this->unwatchTitles( $titles ); + $removed = array_merge( $removed, $titles ); + } + + if ( count( $removed ) > 0 ) { + $this->successMessage = $this->msg( 'watchlistedit-normal-done' + )->numParams( count( $removed ) )->parse(); + $this->showTitles( $removed, $this->successMessage ); + + return true; + } else { + return false; + } + } + + /** + * Get the standard watchlist editing form + * + * @return HTMLForm + */ + protected function getNormalForm() { + global $wgContLang; + + $fields = []; + $count = 0; + + // Allow subscribers to manipulate the list of watched pages (or use it + // to preload lots of details at once) + $watchlistInfo = $this->getWatchlistInfo(); + Hooks::run( + 'WatchlistEditorBeforeFormRender', + [ &$watchlistInfo ] + ); + + foreach ( $watchlistInfo as $namespace => $pages ) { + $options = []; + + foreach ( array_keys( $pages ) as $dbkey ) { + $title = Title::makeTitleSafe( $namespace, $dbkey ); + + if ( $this->checkTitle( $title, $namespace, $dbkey ) ) { + $text = $this->buildRemoveLine( $title ); + $options[$text] = $title->getPrefixedText(); + $count++; + } + } + + // checkTitle can filter some options out, avoid empty sections + if ( count( $options ) > 0 ) { + $fields['TitlesNs' . $namespace] = [ + 'class' => EditWatchlistCheckboxSeriesField::class, + 'options' => $options, + 'section' => "ns$namespace", + ]; + } + } + $this->cleanupWatchlist(); + + if ( count( $fields ) > 1 && $count > 30 ) { + $this->toc = Linker::tocIndent(); + $tocLength = 0; + + foreach ( $fields as $data ) { + # strip out the 'ns' prefix from the section name: + $ns = substr( $data['section'], 2 ); + + $nsText = ( $ns == NS_MAIN ) + ? $this->msg( 'blanknamespace' )->escaped() + : htmlspecialchars( $wgContLang->getFormattedNsText( $ns ) ); + $this->toc .= Linker::tocLine( "editwatchlist-{$data['section']}", $nsText, + $this->getLanguage()->formatNum( ++$tocLength ), 1 ) . Linker::tocLineEnd(); + } + + $this->toc = Linker::tocList( $this->toc ); + } else { + $this->toc = false; + } + + $context = new DerivativeContext( $this->getContext() ); + $context->setTitle( $this->getPageTitle() ); // Remove subpage + $form = new EditWatchlistNormalHTMLForm( $fields, $context ); + $form->setSubmitTextMsg( 'watchlistedit-normal-submit' ); + $form->setSubmitDestructive(); + # Used message keys: + # 'accesskey-watchlistedit-normal-submit', 'tooltip-watchlistedit-normal-submit' + $form->setSubmitTooltip( 'watchlistedit-normal-submit' ); + $form->setWrapperLegendMsg( 'watchlistedit-normal-legend' ); + $form->addHeaderText( $this->msg( 'watchlistedit-normal-explain' )->parse() ); + $form->setSubmitCallback( [ $this, 'submitNormal' ] ); + + return $form; + } + + /** + * Build the label for a checkbox, with a link to the title, and various additional bits + * + * @param Title $title + * @return string + */ + private function buildRemoveLine( $title ) { + $linkRenderer = $this->getLinkRenderer(); + $link = $linkRenderer->makeLink( $title ); + + $tools['talk'] = $linkRenderer->makeLink( + $title->getTalkPage(), + $this->msg( 'talkpagelinktext' )->text() + ); + + if ( $title->exists() ) { + $tools['history'] = $linkRenderer->makeKnownLink( + $title, + $this->msg( 'history_small' )->text(), + [], + [ 'action' => 'history' ] + ); + } + + if ( $title->getNamespace() == NS_USER && !$title->isSubpage() ) { + $tools['contributions'] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Contributions', $title->getText() ), + $this->msg( 'contributions' )->text() + ); + } + + Hooks::run( + 'WatchlistEditorBuildRemoveLine', + [ &$tools, $title, $title->isRedirect(), $this->getSkin(), &$link ] + ); + + if ( $title->isRedirect() ) { + // Linker already makes class mw-redirect, so this is redundant + $link = '<span class="watchlistredir">' . $link . '</span>'; + } + + return $link . ' ' . + $this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList( $tools ) )->escaped(); + } + + /** + * Get a form for editing the watchlist in "raw" mode + * + * @return HTMLForm + */ + protected function getRawForm() { + $titles = implode( "\n", $this->getWatchlist() ); + $fields = [ + 'Titles' => [ + 'type' => 'textarea', + 'label-message' => 'watchlistedit-raw-titles', + 'default' => $titles, + ], + ]; + $context = new DerivativeContext( $this->getContext() ); + $context->setTitle( $this->getPageTitle( 'raw' ) ); // Reset subpage + $form = new HTMLForm( $fields, $context ); + $form->setSubmitTextMsg( 'watchlistedit-raw-submit' ); + # Used message keys: 'accesskey-watchlistedit-raw-submit', 'tooltip-watchlistedit-raw-submit' + $form->setSubmitTooltip( 'watchlistedit-raw-submit' ); + $form->setWrapperLegendMsg( 'watchlistedit-raw-legend' ); + $form->addHeaderText( $this->msg( 'watchlistedit-raw-explain' )->parse() ); + $form->setSubmitCallback( [ $this, 'submitRaw' ] ); + + return $form; + } + + /** + * Get a form for clearing the watchlist + * + * @return HTMLForm + */ + protected function getClearForm() { + $context = new DerivativeContext( $this->getContext() ); + $context->setTitle( $this->getPageTitle( 'clear' ) ); // Reset subpage + $form = new HTMLForm( [], $context ); + $form->setSubmitTextMsg( 'watchlistedit-clear-submit' ); + # Used message keys: 'accesskey-watchlistedit-clear-submit', 'tooltip-watchlistedit-clear-submit' + $form->setSubmitTooltip( 'watchlistedit-clear-submit' ); + $form->setWrapperLegendMsg( 'watchlistedit-clear-legend' ); + $form->addHeaderText( $this->msg( 'watchlistedit-clear-explain' )->parse() ); + $form->setSubmitCallback( [ $this, 'submitClear' ] ); + $form->setSubmitDestructive(); + + return $form; + } + + /** + * Determine whether we are editing the watchlist, and if so, what + * kind of editing operation + * + * @param WebRequest $request + * @param string $par + * @return int + */ + public static function getMode( $request, $par ) { + $mode = strtolower( $request->getVal( 'action', $par ) ); + + switch ( $mode ) { + case 'clear': + case self::EDIT_CLEAR: + return self::EDIT_CLEAR; + case 'raw': + case self::EDIT_RAW: + return self::EDIT_RAW; + case 'edit': + case self::EDIT_NORMAL: + return self::EDIT_NORMAL; + default: + return false; + } + } + + /** + * Build a set of links for convenient navigation + * between watchlist viewing and editing modes + * + * @param Language $lang + * @param LinkRenderer|null $linkRenderer + * @return string + */ + public static function buildTools( $lang, LinkRenderer $linkRenderer = null ) { + if ( !$lang instanceof Language ) { + // back-compat where the first parameter was $unused + global $wgLang; + $lang = $wgLang; + } + if ( !$linkRenderer ) { + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + } + + $tools = []; + $modes = [ + 'view' => [ 'Watchlist', false ], + 'edit' => [ 'EditWatchlist', false ], + 'raw' => [ 'EditWatchlist', 'raw' ], + 'clear' => [ 'EditWatchlist', 'clear' ], + ]; + + foreach ( $modes as $mode => $arr ) { + // can use messages 'watchlisttools-view', 'watchlisttools-edit', 'watchlisttools-raw' + $tools[] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( $arr[0], $arr[1] ), + wfMessage( "watchlisttools-{$mode}" )->text() + ); + } + + return Html::rawElement( + 'span', + [ 'class' => 'mw-watchlist-toollinks' ], + wfMessage( 'parentheses' )->rawParams( $lang->pipeList( $tools ) )->escaped() + ); + } +} diff --git a/www/wiki/includes/specials/SpecialEmailInvalidate.php b/www/wiki/includes/specials/SpecialEmailInvalidate.php new file mode 100644 index 00000000..c54abadd --- /dev/null +++ b/www/wiki/includes/specials/SpecialEmailInvalidate.php @@ -0,0 +1,75 @@ +<?php +/** + * Implements Special:EmailInvalidation + * + * 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 + */ + +/** + * Special page allows users to cancel an email confirmation using the e-mail + * confirmation code + * + * @ingroup SpecialPage + */ +class EmailInvalidation extends UnlistedSpecialPage { + public function __construct() { + parent::__construct( 'Invalidateemail', 'editmyprivateinfo' ); + } + + public function doesWrites() { + return true; + } + + function execute( $code ) { + // Ignore things like master queries/connections on GET requests. + // It's very convenient to just allow formless link usage. + $trxProfiler = Profiler::instance()->getTransactionProfiler(); + + $this->setHeaders(); + $this->checkReadOnly(); + $this->checkPermissions(); + + $old = $trxProfiler->setSilenced( true ); + $this->attemptInvalidate( $code ); + $trxProfiler->setSilenced( $old ); + } + + /** + * Attempt to invalidate the user's email address and show success or failure + * as needed; if successful, link to main page + * + * @param string $code Confirmation code + */ + private function attemptInvalidate( $code ) { + $user = User::newFromConfirmationCode( $code, User::READ_LATEST ); + if ( !is_object( $user ) ) { + $this->getOutput()->addWikiMsg( 'confirmemail_invalid' ); + + return; + } + + $user->invalidateEmail(); + $user->saveSettings(); + $this->getOutput()->addWikiMsg( 'confirmemail_invalidated' ); + + if ( !$this->getUser()->isLoggedIn() ) { + $this->getOutput()->returnToMain(); + } + } +} diff --git a/www/wiki/includes/specials/SpecialEmailuser.php b/www/wiki/includes/specials/SpecialEmailuser.php new file mode 100644 index 00000000..f322ac40 --- /dev/null +++ b/www/wiki/includes/specials/SpecialEmailuser.php @@ -0,0 +1,525 @@ +<?php +/** + * Implements Special:Emailuser + * + * 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 + */ +use MediaWiki\MediaWikiServices; + +/** + * A special page that allows users to send e-mails to other users + * + * @ingroup SpecialPage + */ +class SpecialEmailUser extends UnlistedSpecialPage { + protected $mTarget; + + /** + * @var User|string $mTargetObj + */ + protected $mTargetObj; + + public function __construct() { + parent::__construct( 'Emailuser' ); + } + + public function doesWrites() { + return true; + } + + public function getDescription() { + $target = self::getTarget( $this->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, + 'cols' => 80, + 'label-message' => 'emailmessage', + 'required' => true, + ], + 'CCMe' => [ + 'type' => 'check', + 'label-message' => 'emailccme', + 'default' => $this->getUser()->getBoolOption( 'ccmeonemails' ), + ], + ]; + } + + public function execute( $par ) { + $out = $this->getOutput(); + $out->addModuleStyles( 'mediawiki.special' ); + + $this->mTarget = is_null( $par ) + ? $this->getRequest()->getVal( 'wpTarget', $this->getRequest()->getVal( 'target', '' ) ) + : $par; + + // 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 new UserBlockedError( $this->getUser()->mBlock ); + 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 ); + } + // Got a valid target user name? Else ask for one. + $ret = self::getTarget( $this->mTarget, $this->getUser() ); + if ( !$ret instanceof User ) { + if ( $this->mTarget != '' ) { + // Messages used here: notargettext, noemailtext, nowikiemailtext + $ret = ( $ret == 'notarget' ) ? 'emailnotarget' : ( $ret . 'text' ); + $out->wrapWikiMsg( "<p class='error'>$1</p>", $ret ); + } + $out->addHTML( $this->userForm( $this->mTarget ) ); + + return; + } + + $this->mTargetObj = $ret; + + // Set the 'relevant user' in the skin, so it displays links like Contributions, + // User logs, UserRights, etc. + $this->getSkin()->setRelevantUser( $this->mTargetObj ); + + $context = new DerivativeContext( $this->getContext() ); + $context->setTitle( $this->getPageTitle() ); // Remove subpage + $form = new HTMLForm( $this->getFormFields(), $context ); + // By now we are supposed to be sure that $this->mTarget is a user name + $form->addPreText( $this->msg( 'emailpagetext', $this->mTarget )->parse() ); + $form->setSubmitTextMsg( 'emailsend' ); + $form->setSubmitCallback( [ __CLASS__, 'uiSubmit' ] ); + $form->setWrapperLegendMsg( 'email-legend' ); + $form->loadData(); + + if ( !Hooks::run( 'EmailUserForm', [ &$form ] ) ) { + return; + } + + $result = $form->show(); + + if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) { + $out->setPageTitle( $this->msg( 'emailsent' ) ); + $out->addWikiMsg( 'emailsenttext', $this->mTarget ); + $out->returnToMain( false, $this->mTargetObj->getUserPage() ); + } + } + + /** + * 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 ? $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 ) { + $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 $config optional for backwards compatibility + * @return string|null Null on success or string on 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. + * @return string Form asking for user name. + */ + protected function userForm( $name ) { + $this->getOutput()->addModules( 'mediawiki.userSuggest' ); + $string = Html::openElement( + 'form', + [ 'method' => 'get', 'action' => wfScript(), 'id' => 'askusername' ] + ) . + Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . + Html::openElement( 'fieldset' ) . + Html::rawElement( 'legend', null, $this->msg( 'emailtarget' )->parse() ) . + Html::label( + $this->msg( 'emailusername' )->text(), + 'emailusertarget' + ) . ' ' . + Html::input( + 'target', + $name, + 'text', + [ + 'id' => 'emailusertarget', + 'class' => 'mw-autocomplete-user', // used by mediawiki.userSuggest + 'autofocus' => true, + 'size' => 30, + ] + ) . + ' ' . + Html::submitButton( $this->msg( 'emailusernamesubmit' )->text(), [] ) . + Html::closeElement( 'fieldset' ) . + Html::closeElement( 'form' ) . "\n"; + + return $string; + } + + /** + * Submit callback for an HTMLForm object, will simply call submit(). + * + * @since 1.20 + * @param array $data + * @param HTMLForm $form + * @return Status|bool + */ + public static function uiSubmit( array $data, HTMLForm $form ) { + return self::submit( $data, $form->getContext() ); + } + + /** + * 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' ), + wfMessage( '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' )->rawParams( + $target->getName(), $subject )->text(); + $ccText = $text; + + Hooks::run( 'EmailUserCC', [ &$ccTo, &$ccFrom, &$ccSubject, &$ccText ] ); + + if ( $config->get( 'UserEmailUseReplyTo' ) ) { + $mailFrom = new MailAddress( + $config->get( 'PasswordSender' ), + wfMessage( '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'; + } +} diff --git a/www/wiki/includes/specials/SpecialExpandTemplates.php b/www/wiki/includes/specials/SpecialExpandTemplates.php new file mode 100644 index 00000000..73ca76bb --- /dev/null +++ b/www/wiki/includes/specials/SpecialExpandTemplates.php @@ -0,0 +1,301 @@ +<?php +/** + * Implements Special:ExpandTemplates + * + * 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 + */ + +/** + * A special page that expands submitted templates, parser functions, + * and variables, allowing easier debugging of these. + * + * @ingroup SpecialPage + */ +class SpecialExpandTemplates extends SpecialPage { + + /** @var bool Whether or not to show the XML parse tree */ + protected $generateXML; + + /** @var bool Whether or not to show the raw HTML code */ + protected $generateRawHtml; + + /** @var bool Whether or not to remove comments in the expanded wikitext */ + protected $removeComments; + + /** @var bool Whether or not to remove <nowiki> tags in the expanded wikitext */ + protected $removeNowiki; + + /** @var int Maximum size in bytes to include. 50MB allows fixing those huge pages */ + const MAX_INCLUDE_SIZE = 50000000; + + function __construct() { + parent::__construct( 'ExpandTemplates' ); + } + + /** + * Show the special page + * @param string|null $subpage + */ + function execute( $subpage ) { + global $wgParser; + + $this->setHeaders(); + $this->addHelpLink( 'Help:ExpandTemplates' ); + + $request = $this->getRequest(); + $titleStr = $request->getText( 'wpContextTitle' ); + $title = Title::newFromText( $titleStr ); + + if ( !$title ) { + $title = $this->getPageTitle(); + } + $input = $request->getText( 'wpInput' ); + $this->generateXML = $request->getBool( 'wpGenerateXml' ); + $this->generateRawHtml = $request->getBool( 'wpGenerateRawHtml' ); + + if ( strlen( $input ) ) { + $this->removeComments = $request->getBool( 'wpRemoveComments', false ); + $this->removeNowiki = $request->getBool( 'wpRemoveNowiki', false ); + $options = ParserOptions::newFromContext( $this->getContext() ); + $options->setRemoveComments( $this->removeComments ); + $options->setTidy( true ); + $options->setMaxIncludeSize( self::MAX_INCLUDE_SIZE ); + + if ( $this->generateXML ) { + $wgParser->startExternalParse( $title, $options, Parser::OT_PREPROCESS ); + $dom = $wgParser->preprocessToDom( $input ); + + if ( method_exists( $dom, 'saveXML' ) ) { + $xml = $dom->saveXML(); + } else { + $xml = $dom->__toString(); + } + } + + $output = $wgParser->preprocess( $input, $title, $options ); + } else { + $this->removeComments = $request->getBool( 'wpRemoveComments', true ); + $this->removeNowiki = $request->getBool( 'wpRemoveNowiki', false ); + $output = false; + } + + $out = $this->getOutput(); + + $this->makeForm( $titleStr, $input ); + + if ( $output !== false ) { + if ( $this->generateXML && strlen( $output ) > 0 ) { + $out->addHTML( $this->makeOutput( $xml, 'expand_templates_xml_output' ) ); + } + + $tmp = $this->makeOutput( $output ); + + if ( $this->removeNowiki ) { + $tmp = preg_replace( + [ '_<nowiki>_', '_</nowiki>_', '_<nowiki */>_' ], + '', + $tmp + ); + } + + $config = $this->getConfig(); + if ( $config->get( 'UseTidy' ) && $options->getTidy() ) { + $tmp = MWTidy::tidy( $tmp ); + } + + $out->addHTML( $tmp ); + + $pout = $this->generateHtml( $title, $output ); + $rawhtml = $pout->getText(); + if ( $this->generateRawHtml && strlen( $rawhtml ) > 0 ) { + $out->addHTML( $this->makeOutput( $rawhtml, 'expand_templates_html_output' ) ); + } + + $this->showHtmlPreview( $title, $pout, $out ); + } + } + + /** + * Callback for the HTMLForm used in self::makeForm. + * Checks, if the input was given, and if not, returns a fatal Status + * object with an error message. + * + * @param array $values The values submitted to the HTMLForm + * @return Status + */ + public function onSubmitInput( array $values ) { + $status = Status::newGood(); + if ( !strlen( $values['input'] ) ) { + $status = Status::newFatal( 'expand_templates_input_missing' ); + } + return $status; + } + + /** + * Generate a form allowing users to enter information + * + * @param string $title Value for context title field + * @param string $input Value for input textbox + * @return string + */ + private function makeForm( $title, $input ) { + $fields = [ + 'contexttitle' => [ + 'type' => 'text', + 'label' => $this->msg( 'expand_templates_title' )->plain(), + 'name' => 'wpContextTitle', + 'id' => 'contexttitle', + 'size' => 60, + 'default' => $title, + 'autofocus' => true, + ], + 'input' => [ + 'type' => 'textarea', + 'name' => 'wpInput', + 'label' => $this->msg( 'expand_templates_input' )->text(), + 'rows' => 10, + 'default' => $input, + 'id' => 'input', + 'useeditfont' => true, + ], + 'removecomments' => [ + 'type' => 'check', + 'label' => $this->msg( 'expand_templates_remove_comments' )->text(), + 'name' => 'wpRemoveComments', + 'id' => 'removecomments', + 'default' => $this->removeComments, + ], + 'removenowiki' => [ + 'type' => 'check', + 'label' => $this->msg( 'expand_templates_remove_nowiki' )->text(), + 'name' => 'wpRemoveNowiki', + 'id' => 'removenowiki', + 'default' => $this->removeNowiki, + ], + 'generate_xml' => [ + 'type' => 'check', + 'label' => $this->msg( 'expand_templates_generate_xml' )->text(), + 'name' => 'wpGenerateXml', + 'id' => 'generate_xml', + 'default' => $this->generateXML, + ], + 'generate_rawhtml' => [ + 'type' => 'check', + 'label' => $this->msg( 'expand_templates_generate_rawhtml' )->text(), + 'name' => 'wpGenerateRawHtml', + 'id' => 'generate_rawhtml', + 'default' => $this->generateRawHtml, + ], + ]; + + $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() ); + $form + ->setSubmitTextMsg( 'expand_templates_ok' ) + ->setWrapperLegendMsg( 'expandtemplates' ) + ->setHeaderText( $this->msg( 'expand_templates_intro' )->parse() ) + ->setSubmitCallback( [ $this, 'onSubmitInput' ] ) + ->showAlways(); + } + + /** + * Generate a nice little box with a heading for output + * + * @param string $output Wiki text output + * @param string $heading + * @return string + */ + private function makeOutput( $output, $heading = 'expand_templates_output' ) { + $out = "<h2>" . $this->msg( $heading )->escaped() . "</h2>\n"; + $out .= Xml::textarea( + 'output', + $output, + 10, + 10, + [ + 'id' => 'output', + 'readonly' => 'readonly', + 'class' => 'mw-editfont-' . $this->getUser()->getOption( 'editfont' ) + ] + ); + + return $out; + } + + /** + * Renders the supplied wikitext as html + * + * @param Title $title + * @param string $text + * @return ParserOutput + */ + private function generateHtml( Title $title, $text ) { + global $wgParser; + + $popts = ParserOptions::newFromContext( $this->getContext() ); + $popts->setTargetLanguage( $title->getPageLanguage() ); + return $wgParser->parse( $text, $title, $popts ); + } + + /** + * Wraps the provided html code in a div and outputs it to the page + * + * @param Title $title + * @param ParserOutput $pout + * @param OutputPage $out + */ + private function showHtmlPreview( Title $title, ParserOutput $pout, OutputPage $out ) { + $lang = $title->getPageViewLanguage(); + $out->addHTML( "<h2>" . $this->msg( 'expand_templates_preview' )->escaped() . "</h2>\n" ); + + if ( $this->getConfig()->get( 'RawHtml' ) ) { + $request = $this->getRequest(); + $user = $this->getUser(); + + // To prevent cross-site scripting attacks, don't show the preview if raw HTML is + // allowed and a valid edit token is not provided (T73111). However, MediaWiki + // does not currently provide logged-out users with CSRF protection; in that case, + // do not show the preview unless anonymous editing is allowed. + if ( $user->isAnon() && !$user->isAllowed( 'edit' ) ) { + $error = [ 'expand_templates_preview_fail_html_anon' ]; + } elseif ( !$user->matchEditToken( $request->getVal( 'wpEditToken' ), '', $request ) ) { + $error = [ 'expand_templates_preview_fail_html' ]; + } else { + $error = false; + } + + if ( $error ) { + $out->wrapWikiMsg( "<div class='previewnote'>\n$1\n</div>", $error ); + return; + } + } + + $out->addHTML( Html::openElement( 'div', [ + 'class' => 'mw-content-' . $lang->getDir(), + 'dir' => $lang->getDir(), + 'lang' => $lang->getHtmlCode(), + ] ) ); + $out->addParserOutputContent( $pout ); + $out->addHTML( Html::closeElement( 'div' ) ); + $out->setCategoryLinks( $pout->getCategories() ); + } + + protected function getGroupName() { + return 'wiki'; + } +} diff --git a/www/wiki/includes/specials/SpecialExport.php b/www/wiki/includes/specials/SpecialExport.php new file mode 100644 index 00000000..5a98bb90 --- /dev/null +++ b/www/wiki/includes/specials/SpecialExport.php @@ -0,0 +1,593 @@ +<?php +/** + * Implements Special:Export + * + * Copyright © 2003-2008 Brion Vibber <brion@pobox.com> + * + * 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 + */ + +use MediaWiki\MediaWikiServices; + +/** + * A special page that allows users to export pages in a XML file + * + * @ingroup SpecialPage + */ +class SpecialExport extends SpecialPage { + private $curonly, $doExport, $pageLinkDepth, $templates; + + public function __construct() { + parent::__construct( 'Export' ); + } + + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + $config = $this->getConfig(); + + // Set some variables + $this->curonly = true; + $this->doExport = false; + $request = $this->getRequest(); + $this->templates = $request->getCheck( 'templates' ); + $this->pageLinkDepth = $this->validateLinkDepth( + $request->getIntOrNull( 'pagelink-depth' ) + ); + $nsindex = ''; + $exportall = false; + + if ( $request->getCheck( 'addcat' ) ) { + $page = $request->getText( 'pages' ); + $catname = $request->getText( 'catname' ); + + if ( $catname !== '' && $catname !== null && $catname !== false ) { + $t = Title::makeTitleSafe( NS_MAIN, $catname ); + if ( $t ) { + /** + * @todo FIXME: This can lead to hitting memory limit for very large + * categories. Ideally we would do the lookup synchronously + * during the export in a single query. + */ + $catpages = $this->getPagesFromCategory( $t ); + if ( $catpages ) { + if ( $page !== '' ) { + $page .= "\n"; + } + $page .= implode( "\n", $catpages ); + } + } + } + } elseif ( $request->getCheck( 'addns' ) && $config->get( 'ExportFromNamespaces' ) ) { + $page = $request->getText( 'pages' ); + $nsindex = $request->getText( 'nsindex', '' ); + + if ( strval( $nsindex ) !== '' ) { + /** + * Same implementation as above, so same @todo + */ + $nspages = $this->getPagesFromNamespace( $nsindex ); + if ( $nspages ) { + $page .= "\n" . implode( "\n", $nspages ); + } + } + } elseif ( $request->getCheck( 'exportall' ) && $config->get( 'ExportAllowAll' ) ) { + $this->doExport = true; + $exportall = true; + + /* Although $page and $history are not used later on, we + nevertheless set them to avoid that PHP notices about using + undefined variables foul up our XML output (see call to + doExport(...) further down) */ + $page = ''; + $history = ''; + } elseif ( $request->wasPosted() && $par == '' ) { + $page = $request->getText( 'pages' ); + $this->curonly = $request->getCheck( 'curonly' ); + $rawOffset = $request->getVal( 'offset' ); + + if ( $rawOffset ) { + $offset = wfTimestamp( TS_MW, $rawOffset ); + } else { + $offset = null; + } + + $maxHistory = $config->get( 'ExportMaxHistory' ); + $limit = $request->getInt( 'limit' ); + $dir = $request->getVal( 'dir' ); + $history = [ + 'dir' => 'asc', + 'offset' => false, + 'limit' => $maxHistory, + ]; + $historyCheck = $request->getCheck( 'history' ); + + if ( $this->curonly ) { + $history = WikiExporter::CURRENT; + } elseif ( !$historyCheck ) { + if ( $limit > 0 && ( $maxHistory == 0 || $limit < $maxHistory ) ) { + $history['limit'] = $limit; + } + + if ( !is_null( $offset ) ) { + $history['offset'] = $offset; + } + + if ( strtolower( $dir ) == 'desc' ) { + $history['dir'] = 'desc'; + } + } + + if ( $page != '' ) { + $this->doExport = true; + } + } else { + // Default to current-only for GET requests. + $page = $request->getText( 'pages', $par ); + $historyCheck = $request->getCheck( 'history' ); + + if ( $historyCheck ) { + $history = WikiExporter::FULL; + } else { + $history = WikiExporter::CURRENT; + } + + if ( $page != '' ) { + $this->doExport = true; + } + } + + if ( !$config->get( 'ExportAllowHistory' ) ) { + // Override + $history = WikiExporter::CURRENT; + } + + $list_authors = $request->getCheck( 'listauthors' ); + if ( !$this->curonly || !$config->get( 'ExportAllowListContributors' ) ) { + $list_authors = false; + } + + if ( $this->doExport ) { + $this->getOutput()->disable(); + + // Cancel output buffering and gzipping if set + // This should provide safer streaming for pages with history + wfResetOutputBuffers(); + $request->response()->header( "Content-type: application/xml; charset=utf-8" ); + $request->response()->header( "X-Robots-Tag: noindex,nofollow" ); + + if ( $request->getCheck( 'wpDownload' ) ) { + // Provide a sane filename suggestion + $filename = urlencode( $config->get( 'Sitename' ) . '-' . wfTimestampNow() . '.xml' ); + $request->response()->header( "Content-disposition: attachment;filename={$filename}" ); + } + + $this->doExport( $page, $history, $list_authors, $exportall ); + + return; + } + + $out = $this->getOutput(); + $out->addWikiMsg( 'exporttext' ); + + if ( $page == '' ) { + $categoryName = $request->getText( 'catname' ); + } else { + $categoryName = ''; + } + + $formDescriptor = [ + 'catname' => [ + 'type' => 'textwithbutton', + 'name' => 'catname', + 'horizontal-label' => true, + 'label-message' => 'export-addcattext', + 'default' => $categoryName, + 'size' => 40, + 'buttontype' => 'submit', + 'buttonname' => 'addcat', + 'buttondefault' => $this->msg( 'export-addcat' )->text(), + 'hide-if' => [ '===', 'exportall', '1' ], + ], + ]; + if ( $config->get( 'ExportFromNamespaces' ) ) { + $formDescriptor += [ + 'nsindex' => [ + 'type' => 'namespaceselectwithbutton', + 'default' => $nsindex, + 'label-message' => 'export-addnstext', + 'horizontal-label' => true, + 'name' => 'nsindex', + 'id' => 'namespace', + 'cssclass' => 'namespaceselector', + 'buttontype' => 'submit', + 'buttonname' => 'addns', + 'buttondefault' => $this->msg( 'export-addns' )->text(), + 'hide-if' => [ '===', 'exportall', '1' ], + ], + ]; + } + + if ( $config->get( 'ExportAllowAll' ) ) { + $formDescriptor += [ + 'exportall' => [ + 'type' => 'check', + 'label-message' => 'exportall', + 'name' => 'exportall', + 'id' => 'exportall', + 'default' => $request->wasPosted() ? $request->getCheck( 'exportall' ) : false, + ], + ]; + } + + $formDescriptor += [ + 'textarea' => [ + 'class' => HTMLTextAreaField::class, + 'name' => 'pages', + 'label-message' => 'export-manual', + 'nodata' => true, + 'rows' => 10, + 'default' => $page, + 'hide-if' => [ '===', 'exportall', '1' ], + ], + ]; + + if ( $config->get( 'ExportAllowHistory' ) ) { + $formDescriptor += [ + 'curonly' => [ + 'type' => 'check', + 'label-message' => 'exportcuronly', + 'name' => 'curonly', + 'id' => 'curonly', + 'default' => $request->wasPosted() ? $request->getCheck( 'curonly' ) : true, + ], + ]; + } else { + $out->addWikiMsg( 'exportnohistory' ); + } + + $formDescriptor += [ + 'templates' => [ + 'type' => 'check', + 'label-message' => 'export-templates', + 'name' => 'templates', + 'id' => 'wpExportTemplates', + 'default' => $request->wasPosted() ? $request->getCheck( 'templates' ) : false, + ], + ]; + + if ( $config->get( 'ExportMaxLinkDepth' ) || $this->userCanOverrideExportDepth() ) { + $formDescriptor += [ + 'pagelink-depth' => [ + 'type' => 'text', + 'name' => 'pagelink-depth', + 'id' => 'pagelink-depth', + 'label-message' => 'export-pagelinks', + 'default' => '0', + 'size' => 20, + ], + ]; + } + + $formDescriptor += [ + 'wpDownload' => [ + 'type' => 'check', + 'name' => 'wpDownload', + 'id' => 'wpDownload', + 'default' => $request->wasPosted() ? $request->getCheck( 'wpDownload' ) : true, + 'label-message' => 'export-download', + ], + ]; + + if ( $config->get( 'ExportAllowListContributors' ) ) { + $formDescriptor += [ + 'listauthors' => [ + 'type' => 'check', + 'label-message' => 'exportlistauthors', + 'default' => $request->wasPosted() ? $request->getCheck( 'listauthors' ) : false, + 'name' => 'listauthors', + 'id' => 'listauthors', + ], + ]; + } + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm->setSubmitTextMsg( 'export-submit' ); + $htmlForm->prepareForm()->displayForm( false ); + $this->addHelpLink( 'Help:Export' ); + } + + /** + * @return bool + */ + private function userCanOverrideExportDepth() { + return $this->getUser()->isAllowed( 'override-export-depth' ); + } + + /** + * Do the actual page exporting + * + * @param string $page User input on what page(s) to export + * @param int $history One of the WikiExporter history export constants + * @param bool $list_authors Whether to add distinct author list (when + * not returning full history) + * @param bool $exportall Whether to export everything + */ + private function doExport( $page, $history, $list_authors, $exportall ) { + // If we are grabbing everything, enable full history and ignore the rest + if ( $exportall ) { + $history = WikiExporter::FULL; + } else { + $pageSet = []; // Inverted index of all pages to look up + + // Split up and normalize input + foreach ( explode( "\n", $page ) as $pageName ) { + $pageName = trim( $pageName ); + $title = Title::newFromText( $pageName ); + if ( $title && !$title->isExternal() && $title->getText() !== '' ) { + // Only record each page once! + $pageSet[$title->getPrefixedText()] = true; + } + } + + // Set of original pages to pass on to further manipulation... + $inputPages = array_keys( $pageSet ); + + // Look up any linked pages if asked... + if ( $this->templates ) { + $pageSet = $this->getTemplates( $inputPages, $pageSet ); + } + $linkDepth = $this->pageLinkDepth; + if ( $linkDepth ) { + $pageSet = $this->getPageLinks( $inputPages, $pageSet, $linkDepth ); + } + + $pages = array_keys( $pageSet ); + + // Normalize titles to the same format and remove dupes, see T19374 + foreach ( $pages as $k => $v ) { + $pages[$k] = str_replace( " ", "_", $v ); + } + + $pages = array_unique( $pages ); + } + + /* Ok, let's get to it... */ + if ( $history == WikiExporter::CURRENT ) { + $lb = false; + $db = wfGetDB( DB_REPLICA ); + $buffer = WikiExporter::BUFFER; + } else { + // Use an unbuffered query; histories may be very long! + $lb = MediaWikiServices::getInstance()->getDBLoadBalancerFactory()->newMainLB(); + $db = $lb->getConnection( DB_REPLICA ); + $buffer = WikiExporter::STREAM; + + // This might take a while... :D + Wikimedia\suppressWarnings(); + set_time_limit( 0 ); + Wikimedia\restoreWarnings(); + } + + $exporter = new WikiExporter( $db, $history, $buffer ); + $exporter->list_authors = $list_authors; + $exporter->openStream(); + + if ( $exportall ) { + $exporter->allPages(); + } else { + foreach ( $pages as $page ) { + # T10824: Only export pages the user can read + $title = Title::newFromText( $page ); + if ( is_null( $title ) ) { + // @todo Perhaps output an <error> tag or something. + continue; + } + + if ( !$title->userCan( 'read', $this->getUser() ) ) { + // @todo Perhaps output an <error> tag or something. + continue; + } + + $exporter->pageByTitle( $title ); + } + } + + $exporter->closeStream(); + + if ( $lb ) { + $lb->closeAll(); + } + } + + /** + * @param Title $title + * @return array + */ + private function getPagesFromCategory( $title ) { + global $wgContLang; + + $maxPages = $this->getConfig()->get( 'ExportPagelistLimit' ); + + $name = $title->getDBkey(); + + $dbr = wfGetDB( DB_REPLICA ); + $res = $dbr->select( + [ 'page', 'categorylinks' ], + [ 'page_namespace', 'page_title' ], + [ 'cl_from=page_id', 'cl_to' => $name ], + __METHOD__, + [ 'LIMIT' => $maxPages ] + ); + + $pages = []; + + foreach ( $res as $row ) { + $n = $row->page_title; + if ( $row->page_namespace ) { + $ns = $wgContLang->getNsText( $row->page_namespace ); + $n = $ns . ':' . $n; + } + + $pages[] = $n; + } + + return $pages; + } + + /** + * @param int $nsindex + * @return array + */ + private function getPagesFromNamespace( $nsindex ) { + global $wgContLang; + + $maxPages = $this->getConfig()->get( 'ExportPagelistLimit' ); + + $dbr = wfGetDB( DB_REPLICA ); + $res = $dbr->select( + 'page', + [ 'page_namespace', 'page_title' ], + [ 'page_namespace' => $nsindex ], + __METHOD__, + [ 'LIMIT' => $maxPages ] + ); + + $pages = []; + + foreach ( $res as $row ) { + $n = $row->page_title; + + if ( $row->page_namespace ) { + $ns = $wgContLang->getNsText( $row->page_namespace ); + $n = $ns . ':' . $n; + } + + $pages[] = $n; + } + + return $pages; + } + + /** + * Expand a list of pages to include templates used in those pages. + * @param array $inputPages List of titles to look up + * @param array $pageSet Associative array indexed by titles for output + * @return array Associative array index by titles + */ + private function getTemplates( $inputPages, $pageSet ) { + return $this->getLinks( $inputPages, $pageSet, + 'templatelinks', + [ 'namespace' => 'tl_namespace', 'title' => 'tl_title' ], + [ 'page_id=tl_from' ] + ); + } + + /** + * Validate link depth setting, if available. + * @param int $depth + * @return int + */ + private function validateLinkDepth( $depth ) { + if ( $depth < 0 ) { + return 0; + } + + if ( !$this->userCanOverrideExportDepth() ) { + $maxLinkDepth = $this->getConfig()->get( 'ExportMaxLinkDepth' ); + if ( $depth > $maxLinkDepth ) { + return $maxLinkDepth; + } + } + + /* + * There's a HARD CODED limit of 5 levels of recursion here to prevent a + * crazy-big export from being done by someone setting the depth + * number too high. In other words, last resort safety net. + */ + + return intval( min( $depth, 5 ) ); + } + + /** + * Expand a list of pages to include pages linked to from that page. + * @param array $inputPages + * @param array $pageSet + * @param int $depth + * @return array + */ + private function getPageLinks( $inputPages, $pageSet, $depth ) { + for ( ; $depth > 0; --$depth ) { + $pageSet = $this->getLinks( + $inputPages, $pageSet, 'pagelinks', + [ 'namespace' => 'pl_namespace', 'title' => 'pl_title' ], + [ 'page_id=pl_from' ] + ); + $inputPages = array_keys( $pageSet ); + } + + return $pageSet; + } + + /** + * Expand a list of pages to include items used in those pages. + * @param array $inputPages Array of page titles + * @param array $pageSet + * @param string $table + * @param array $fields Array of field names + * @param array $join + * @return array + */ + private function getLinks( $inputPages, $pageSet, $table, $fields, $join ) { + $dbr = wfGetDB( DB_REPLICA ); + + foreach ( $inputPages as $page ) { + $title = Title::newFromText( $page ); + + if ( $title ) { + $pageSet[$title->getPrefixedText()] = true; + /// @todo FIXME: May or may not be more efficient to batch these + /// by namespace when given multiple input pages. + $result = $dbr->select( + [ 'page', $table ], + $fields, + array_merge( + $join, + [ + 'page_namespace' => $title->getNamespace(), + 'page_title' => $title->getDBkey() + ] + ), + __METHOD__ + ); + + foreach ( $result as $row ) { + $template = Title::makeTitle( $row->namespace, $row->title ); + $pageSet[$template->getPrefixedText()] = true; + } + } + } + + return $pageSet; + } + + protected function getGroupName() { + return 'pagetools'; + } +} diff --git a/www/wiki/includes/specials/SpecialFewestrevisions.php b/www/wiki/includes/specials/SpecialFewestrevisions.php new file mode 100644 index 00000000..f20829fd --- /dev/null +++ b/www/wiki/includes/specials/SpecialFewestrevisions.php @@ -0,0 +1,105 @@ +<?php +/** + * Implements Special:Fewestrevisions + * + * 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 + */ + +/** + * Special page for listing the articles with the fewest revisions. + * + * @ingroup SpecialPage + * @author Martin Drashkov + */ +class FewestrevisionsPage extends QueryPage { + function __construct( $name = 'Fewestrevisions' ) { + parent::__construct( $name ); + } + + public function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + public function getQueryInfo() { + return [ + 'tables' => [ 'revision', 'page' ], + 'fields' => [ + 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'COUNT(*)', + 'redirect' => 'page_is_redirect' + ], + 'conds' => [ + 'page_namespace' => MWNamespace::getContentNamespaces(), + 'page_id = rev_page' ], + 'options' => [ + 'GROUP BY' => [ 'page_namespace', 'page_title', 'page_is_redirect' ] + ] + ]; + } + + function sortDescending() { + return false; + } + + /** + * @param Skin $skin + * @param object $result Database row + * @return string + */ + function formatResult( $skin, $result ) { + global $wgContLang; + + $nt = Title::makeTitleSafe( $result->namespace, $result->title ); + if ( !$nt ) { + return Html::element( + 'span', + [ 'class' => 'mw-invalidtitle' ], + Linker::getInvalidTitleDescription( + $this->getContext(), + $result->namespace, + $result->title + ) + ); + } + $linkRenderer = $this->getLinkRenderer(); + $text = $wgContLang->convert( $nt->getPrefixedText() ); + $plink = $linkRenderer->makeLink( $nt, $text ); + + $nl = $this->msg( 'nrevisions' )->numParams( $result->value )->text(); + $redirect = isset( $result->redirect ) && $result->redirect ? + ' - ' . $this->msg( 'isredirect' )->escaped() : ''; + $nlink = $linkRenderer->makeKnownLink( + $nt, + $nl, + [], + [ 'action' => 'history' ] + ) . $redirect; + + return $this->getLanguage()->specialList( $plink, $nlink ); + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialFileDuplicateSearch.php b/www/wiki/includes/specials/SpecialFileDuplicateSearch.php new file mode 100644 index 00000000..7694a610 --- /dev/null +++ b/www/wiki/includes/specials/SpecialFileDuplicateSearch.php @@ -0,0 +1,267 @@ +<?php +use MediaWiki\MediaWikiServices; + +/** + * Implements Special:FileDuplicateSearch + * + * 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 + * @author Raimond Spekking, based on Special:MIMESearch by Ævar Arnfjörð Bjarmason + */ + +/** + * Searches the database for files of the requested hash, comparing this with the + * 'img_sha1' field in the image table. + * + * @ingroup SpecialPage + */ +class FileDuplicateSearchPage extends QueryPage { + protected $hash = '', $filename = ''; + + /** + * @var File $file selected reference file, if present + */ + protected $file = null; + + function __construct( $name = 'FileDuplicateSearch' ) { + parent::__construct( $name ); + } + + function isSyndicated() { + return false; + } + + function isCacheable() { + return false; + } + + public function isCached() { + return false; + } + + function linkParameters() { + return [ 'filename' => $this->filename ]; + } + + /** + * Fetch dupes from all connected file repositories. + * + * @return array Array of File objects + */ + function getDupes() { + return RepoGroup::singleton()->findBySha1( $this->hash ); + } + + /** + * + * @param array $dupes Array of File objects + */ + function showList( $dupes ) { + $html = []; + $html[] = $this->openList( 0 ); + + foreach ( $dupes as $dupe ) { + $line = $this->formatResult( null, $dupe ); + $html[] = "<li>" . $line . "</li>"; + } + $html[] = $this->closeList(); + + $this->getOutput()->addHTML( implode( "\n", $html ) ); + } + + public function getQueryInfo() { + $imgQuery = LocalFile::getQueryInfo(); + return [ + 'tables' => $imgQuery['tables'], + 'fields' => [ + 'title' => 'img_name', + 'value' => 'img_sha1', + 'img_user_text' => $imgQuery['fields']['img_user_text'], + 'img_timestamp' + ], + 'conds' => [ 'img_sha1' => $this->hash ], + 'join_conds' => $imgQuery['joins'], + ]; + } + + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + + $this->filename = $par !== null ? $par : $this->getRequest()->getText( 'filename' ); + $this->file = null; + $this->hash = ''; + $title = Title::newFromText( $this->filename, NS_FILE ); + if ( $title && $title->getText() != '' ) { + $this->file = wfFindFile( $title ); + } + + $out = $this->getOutput(); + + # Create the input form + $formFields = [ + 'filename' => [ + 'type' => 'text', + 'name' => 'filename', + 'label-message' => 'fileduplicatesearch-filename', + 'id' => 'filename', + 'size' => 50, + 'value' => $this->filename, + ], + ]; + $hiddenFields = [ + 'title' => $this->getPageTitle()->getPrefixedDBkey(), + ]; + $htmlForm = HTMLForm::factory( 'ooui', $formFields, $this->getContext() ); + $htmlForm->addHiddenFields( $hiddenFields ); + $htmlForm->setAction( wfScript() ); + $htmlForm->setMethod( 'get' ); + $htmlForm->setSubmitProgressive(); + $htmlForm->setSubmitTextMsg( $this->msg( 'fileduplicatesearch-submit' ) ); + + // The form should be visible always, even if it was submitted (e.g. to perform another action). + // To bypass the callback validation of HTMLForm, use prepareForm() and displayForm(). + $htmlForm->prepareForm()->displayForm( false ); + + if ( $this->file ) { + $this->hash = $this->file->getSha1(); + } elseif ( $this->filename !== '' ) { + $out->wrapWikiMsg( + "<p class='mw-fileduplicatesearch-noresults'>\n$1\n</p>", + [ 'fileduplicatesearch-noresults', wfEscapeWikiText( $this->filename ) ] + ); + } + + if ( $this->hash != '' ) { + # Show a thumbnail of the file + $img = $this->file; + if ( $img ) { + $thumb = $img->transform( [ 'width' => 120, 'height' => 120 ] ); + if ( $thumb ) { + $out->addModuleStyles( 'mediawiki.special' ); + $out->addHTML( '<div id="mw-fileduplicatesearch-icon">' . + $thumb->toHtml( [ 'desc-link' => false ] ) . '<br />' . + $this->msg( 'fileduplicatesearch-info' )->numParams( + $img->getWidth(), $img->getHeight() )->params( + $this->getLanguage()->formatSize( $img->getSize() ), + $img->getMimeType() )->parseAsBlock() . + '</div>' ); + } + } + + $dupes = $this->getDupes(); + $numRows = count( $dupes ); + + # Show a short summary + if ( $numRows == 1 ) { + $out->wrapWikiMsg( + "<p class='mw-fileduplicatesearch-result-1'>\n$1\n</p>", + [ 'fileduplicatesearch-result-1', wfEscapeWikiText( $this->filename ) ] + ); + } elseif ( $numRows ) { + $out->wrapWikiMsg( + "<p class='mw-fileduplicatesearch-result-n'>\n$1\n</p>", + [ 'fileduplicatesearch-result-n', wfEscapeWikiText( $this->filename ), + $this->getLanguage()->formatNum( $numRows - 1 ) ] + ); + } + + $this->doBatchLookups( $dupes ); + $this->showList( $dupes ); + } + } + + function doBatchLookups( $list ) { + $batch = new LinkBatch(); + /** @var File $file */ + foreach ( $list as $file ) { + $batch->addObj( $file->getTitle() ); + if ( $file->isLocal() ) { + $userName = $file->getUser( 'text' ); + $batch->add( NS_USER, $userName ); + $batch->add( NS_USER_TALK, $userName ); + } + } + + $batch->execute(); + } + + /** + * + * @param Skin $skin + * @param File $result + * @return string HTML + */ + function formatResult( $skin, $result ) { + global $wgContLang; + + $linkRenderer = $this->getLinkRenderer(); + $nt = $result->getTitle(); + $text = $wgContLang->convert( $nt->getText() ); + $plink = $linkRenderer->makeLink( + $nt, + $text + ); + + $userText = $result->getUser( 'text' ); + if ( $result->isLocal() ) { + $userId = $result->getUser( 'id' ); + $user = Linker::userLink( $userId, $userText ); + $user .= '<span style="white-space: nowrap;">'; + $user .= Linker::userToolLinks( $userId, $userText ); + $user .= '</span>'; + } else { + $user = htmlspecialchars( $userText ); + } + + $time = htmlspecialchars( $this->getLanguage()->userTimeAndDate( + $result->getTimestamp(), $this->getUser() ) ); + + return "$plink . . $user . . $time"; + } + + /** + * 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 ) { + $title = Title::newFromText( $search, NS_FILE ); + if ( !$title || $title->getNamespace() !== NS_FILE ) { + // No prefix suggestion outside of file namespace + return []; + } + $searchEngine = MediaWikiServices::getInstance()->newSearchEngine(); + $searchEngine->setLimitOffset( $limit, $offset ); + // Autocomplete subpage the same as a normal search, but just for files + $searchEngine->setNamespaces( [ NS_FILE ] ); + $result = $searchEngine->defaultPrefixSearch( $search ); + + return array_map( function ( Title $t ) { + // Remove namespace in search suggestion + return $t->getText(); + }, $result ); + } + + protected function getGroupName() { + return 'media'; + } +} diff --git a/www/wiki/includes/specials/SpecialFilepath.php b/www/wiki/includes/specials/SpecialFilepath.php new file mode 100644 index 00000000..c18faa12 --- /dev/null +++ b/www/wiki/includes/specials/SpecialFilepath.php @@ -0,0 +1,55 @@ +<?php +/** + * Implements Special:Filepath + * + * 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 + */ + +/** + * A special page that redirects to the URL of a given file + * + * @ingroup SpecialPage + */ +class SpecialFilepath extends RedirectSpecialPage { + public function __construct() { + parent::__construct( 'Filepath' ); + $this->mAllowedRedirectParams = [ 'width', 'height' ]; + } + + /** + * Implement by redirecting through Special:Redirect/file. + * + * @param string|null $par + * @return Title + */ + public function getRedirect( $par ) { + $file = $par ?: $this->getRequest()->getText( 'file' ); + + if ( $file ) { + $argument = "file/$file"; + } else { + $argument = 'file'; + } + return SpecialPage::getSafeTitleFor( 'Redirect', $argument ); + } + + protected function getGroupName() { + return 'media'; + } +} diff --git a/www/wiki/includes/specials/SpecialGoToInterwiki.php b/www/wiki/includes/specials/SpecialGoToInterwiki.php new file mode 100644 index 00000000..809a14aa --- /dev/null +++ b/www/wiki/includes/specials/SpecialGoToInterwiki.php @@ -0,0 +1,79 @@ +<?php +/** + * Implements Special:GoToInterwiki + * + * 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 + */ + +/** + * Landing page for non-local interwiki links. + * + * Meant to warn people that the site they're visiting + * is not the local wiki (In case of phishing tricks). + * Only meant to be used for things that directly + * redirect from url (e.g. Special:Search/google:foo ) + * Not meant for general interwiki linking (e.g. + * [[google:foo]] should still directly link) + * + * @ingroup SpecialPage + */ +class SpecialGoToInterwiki extends UnlistedSpecialPage { + public function __construct( $name = 'GoToInterwiki' ) { + parent::__construct( $name ); + } + + public function execute( $par ) { + $this->setHeaders(); + $target = Title::newFromText( $par ); + // Disallow special pages as a precaution against + // possible redirect loops. + if ( !$target || $target->isSpecialPage() ) { + $this->getOutput()->setStatusCode( 404 ); + $this->getOutput()->addWikiMsg( 'gotointerwiki-invalid' ); + return; + } + + $url = $target->getFullURL(); + if ( !$target->isExternal() || $target->isLocal() ) { + // Either a normal page, or a local interwiki. + // just redirect. + $this->getOutput()->redirect( $url, '301' ); + } else { + $this->getOutput()->addWikiMsg( + 'gotointerwiki-external', + $url, + $target->getFullText() + ); + } + } + + /** + * @return bool + */ + public function requiresWrite() { + return false; + } + + /** + * @return String + */ + protected function getGroupName() { + return 'redirects'; + } +} diff --git a/www/wiki/includes/specials/SpecialImport.php b/www/wiki/includes/specials/SpecialImport.php new file mode 100644 index 00000000..ab5d4d72 --- /dev/null +++ b/www/wiki/includes/specials/SpecialImport.php @@ -0,0 +1,566 @@ +<?php +/** + * Implements Special:Import + * + * Copyright © 2003,2005 Brion Vibber <brion@pobox.com> + * https://www.mediawiki.org/ + * + * 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 + */ + +/** + * MediaWiki page data importer + * + * @ingroup SpecialPage + */ +class SpecialImport extends SpecialPage { + private $sourceName = false; + private $interwiki = false; + private $subproject; + private $fullInterwikiPrefix; + private $mapping = 'default'; + private $namespace; + private $rootpage = ''; + private $frompage = ''; + private $logcomment = false; + private $history = true; + private $includeTemplates = false; + private $pageLinkDepth; + private $importSources; + private $assignKnownUsers; + private $usernamePrefix; + + public function __construct() { + parent::__construct( 'Import', 'import' ); + } + + public function doesWrites() { + return true; + } + + /** + * Execute + * @param string|null $par + * @throws PermissionsError + * @throws ReadOnlyError + */ + function execute( $par ) { + $this->useTransactionalTimeLimit(); + + $this->setHeaders(); + $this->outputHeader(); + + $this->namespace = $this->getConfig()->get( 'ImportTargetNamespace' ); + + $this->getOutput()->addModules( 'mediawiki.special.import' ); + + $this->importSources = $this->getConfig()->get( 'ImportSources' ); + Hooks::run( 'ImportSources', [ &$this->importSources ] ); + + $user = $this->getUser(); + if ( !$user->isAllowedAny( 'import', 'importupload' ) ) { + throw new PermissionsError( 'import' ); + } + + # @todo Allow Title::getUserPermissionsErrors() to take an array + # @todo FIXME: Title::checkSpecialsAndNSPermissions() has a very wierd expectation of what + # getUserPermissionsErrors() might actually be used for, hence the 'ns-specialprotected' + $errors = wfMergeErrorArrays( + $this->getPageTitle()->getUserPermissionsErrors( + 'import', $user, true, + [ 'ns-specialprotected', 'badaccess-group0', 'badaccess-groups' ] + ), + $this->getPageTitle()->getUserPermissionsErrors( + 'importupload', $user, true, + [ 'ns-specialprotected', 'badaccess-group0', 'badaccess-groups' ] + ) + ); + + if ( $errors ) { + throw new PermissionsError( 'import', $errors ); + } + + $this->checkReadOnly(); + + $request = $this->getRequest(); + if ( $request->wasPosted() && $request->getVal( 'action' ) == 'submit' ) { + $this->doImport(); + } + $this->showForm(); + } + + /** + * Do the actual import + */ + private function doImport() { + $isUpload = false; + $request = $this->getRequest(); + $this->sourceName = $request->getVal( "source" ); + $this->assignKnownUsers = $request->getCheck( 'assignKnownUsers' ); + + $this->logcomment = $request->getText( 'log-comment' ); + $this->pageLinkDepth = $this->getConfig()->get( 'ExportMaxLinkDepth' ) == 0 + ? 0 + : $request->getIntOrNull( 'pagelink-depth' ); + + $this->mapping = $request->getVal( 'mapping' ); + if ( $this->mapping === 'namespace' ) { + $this->namespace = $request->getIntOrNull( 'namespace' ); + } elseif ( $this->mapping === 'subpage' ) { + $this->rootpage = $request->getText( 'rootpage' ); + } else { + $this->mapping = 'default'; + } + + $user = $this->getUser(); + if ( !$user->matchEditToken( $request->getVal( 'editToken' ) ) ) { + $source = Status::newFatal( 'import-token-mismatch' ); + } elseif ( $this->sourceName === 'upload' ) { + $isUpload = true; + $this->usernamePrefix = $this->fullInterwikiPrefix = $request->getVal( 'usernamePrefix' ); + if ( $user->isAllowed( 'importupload' ) ) { + $source = ImportStreamSource::newFromUpload( "xmlimport" ); + } else { + throw new PermissionsError( 'importupload' ); + } + } elseif ( $this->sourceName === 'interwiki' ) { + if ( !$user->isAllowed( 'import' ) ) { + throw new PermissionsError( 'import' ); + } + $this->interwiki = $this->fullInterwikiPrefix = $request->getVal( 'interwiki' ); + // does this interwiki have subprojects? + $hasSubprojects = array_key_exists( $this->interwiki, $this->importSources ); + if ( !$hasSubprojects && !in_array( $this->interwiki, $this->importSources ) ) { + $source = Status::newFatal( "import-invalid-interwiki" ); + } else { + if ( $hasSubprojects ) { + $this->subproject = $request->getVal( 'subproject' ); + $this->fullInterwikiPrefix .= ':' . $request->getVal( 'subproject' ); + } + if ( $hasSubprojects && + !in_array( $this->subproject, $this->importSources[$this->interwiki] ) + ) { + $source = Status::newFatal( "import-invalid-interwiki" ); + } else { + $this->history = $request->getCheck( 'interwikiHistory' ); + $this->frompage = $request->getText( "frompage" ); + $this->includeTemplates = $request->getCheck( 'interwikiTemplates' ); + $source = ImportStreamSource::newFromInterwiki( + $this->fullInterwikiPrefix, + $this->frompage, + $this->history, + $this->includeTemplates, + $this->pageLinkDepth ); + } + } + } else { + $source = Status::newFatal( "importunknownsource" ); + } + + if ( (string)$this->fullInterwikiPrefix === '' ) { + $source->fatal( 'importnoprefix' ); + } + + $out = $this->getOutput(); + if ( !$source->isGood() ) { + $out->addWikiText( "<p class=\"error\">\n" . + $this->msg( 'importfailed', $source->getWikiText() )->parse() . "\n</p>" ); + } else { + $importer = new WikiImporter( $source->value, $this->getConfig() ); + if ( !is_null( $this->namespace ) ) { + $importer->setTargetNamespace( $this->namespace ); + } elseif ( !is_null( $this->rootpage ) ) { + $statusRootPage = $importer->setTargetRootPage( $this->rootpage ); + if ( !$statusRootPage->isGood() ) { + $out->wrapWikiMsg( + "<p class=\"error\">\n$1\n</p>", + [ + 'import-options-wrong', + $statusRootPage->getWikiText(), + count( $statusRootPage->getErrorsArray() ) + ] + ); + + return; + } + } + $importer->setUsernamePrefix( $this->fullInterwikiPrefix, $this->assignKnownUsers ); + + $out->addWikiMsg( "importstart" ); + + $reporter = new ImportReporter( + $importer, + $isUpload, + $this->fullInterwikiPrefix, + $this->logcomment + ); + $reporter->setContext( $this->getContext() ); + $exception = false; + + $reporter->open(); + try { + $importer->doImport(); + } catch ( Exception $e ) { + $exception = $e; + } + $result = $reporter->close(); + + if ( $exception ) { + # No source or XML parse error + $out->wrapWikiMsg( + "<p class=\"error\">\n$1\n</p>", + [ 'importfailed', $exception->getMessage() ] + ); + } elseif ( !$result->isGood() ) { + # Zero revisions + $out->wrapWikiMsg( + "<p class=\"error\">\n$1\n</p>", + [ 'importfailed', $result->getWikiText() ] + ); + } else { + # Success! + $out->addWikiMsg( 'importsuccess' ); + } + $out->addHTML( '<hr />' ); + } + } + + private function getMappingFormPart( $sourceName ) { + $isSameSourceAsBefore = ( $this->sourceName === $sourceName ); + $defaultNamespace = $this->getConfig()->get( 'ImportTargetNamespace' ); + return "<tr> + <td> + </td> + <td class='mw-input'>" . + Xml::radioLabel( + $this->msg( 'import-mapping-default' )->text(), + 'mapping', + 'default', + // mw-import-mapping-interwiki-default, mw-import-mapping-upload-default + "mw-import-mapping-$sourceName-default", + ( $isSameSourceAsBefore ? + ( $this->mapping === 'default' ) : + is_null( $defaultNamespace ) ) + ) . + "</td> + </tr> + <tr> + <td> + </td> + <td class='mw-input'>" . + Xml::radioLabel( + $this->msg( 'import-mapping-namespace' )->text(), + 'mapping', + 'namespace', + // mw-import-mapping-interwiki-namespace, mw-import-mapping-upload-namespace + "mw-import-mapping-$sourceName-namespace", + ( $isSameSourceAsBefore ? + ( $this->mapping === 'namespace' ) : + !is_null( $defaultNamespace ) ) + ) . ' ' . + Html::namespaceSelector( + [ + 'selected' => ( $isSameSourceAsBefore ? + $this->namespace : + ( $defaultNamespace || '' ) ), + ], [ + 'name' => "namespace", + // mw-import-namespace-interwiki, mw-import-namespace-upload + 'id' => "mw-import-namespace-$sourceName", + 'class' => 'namespaceselector', + ] + ) . + "</td> + </tr> + <tr> + <td> + </td> + <td class='mw-input'>" . + Xml::radioLabel( + $this->msg( 'import-mapping-subpage' )->text(), + 'mapping', + 'subpage', + // mw-import-mapping-interwiki-subpage, mw-import-mapping-upload-subpage + "mw-import-mapping-$sourceName-subpage", + ( $isSameSourceAsBefore ? ( $this->mapping === 'subpage' ) : '' ) + ) . ' ' . + Xml::input( 'rootpage', 50, + ( $isSameSourceAsBefore ? $this->rootpage : '' ), + [ + // Should be "mw-import-rootpage-...", but we keep this inaccurate + // ID for legacy reasons + // mw-interwiki-rootpage-interwiki, mw-interwiki-rootpage-upload + 'id' => "mw-interwiki-rootpage-$sourceName", + 'type' => 'text' + ] + ) . ' ' . + "</td> + </tr>"; + } + + private function showForm() { + $action = $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ); + $user = $this->getUser(); + $out = $this->getOutput(); + $this->addHelpLink( '//meta.wikimedia.org/wiki/Special:MyLanguage/Help:Import', true ); + + if ( $user->isAllowed( 'importupload' ) ) { + $mappingSelection = $this->getMappingFormPart( 'upload' ); + $out->addHTML( + Xml::fieldset( $this->msg( 'import-upload' )->text() ) . + Xml::openElement( + 'form', + [ + 'enctype' => 'multipart/form-data', + 'method' => 'post', + 'action' => $action, + 'id' => 'mw-import-upload-form' + ] + ) . + $this->msg( 'importtext' )->parseAsBlock() . + Html::hidden( 'action', 'submit' ) . + Html::hidden( 'source', 'upload' ) . + Xml::openElement( 'table', [ 'id' => 'mw-import-table-upload' ] ) . + "<tr> + <td class='mw-label'>" . + Xml::label( $this->msg( 'import-upload-filename' )->text(), 'xmlimport' ) . + "</td> + <td class='mw-input'>" . + Html::input( 'xmlimport', '', 'file', [ 'id' => 'xmlimport' ] ) . ' ' . + "</td> + </tr> + <tr> + <td class='mw-label'>" . + Xml::label( $this->msg( 'import-upload-username-prefix' )->text(), + 'mw-import-usernamePrefix' ) . + "</td> + <td class='mw-input'>" . + Xml::input( 'usernamePrefix', 50, + $this->usernamePrefix, + [ 'id' => 'usernamePrefix', 'type' => 'text' ] ) . ' ' . + "</td> + </tr> + <tr> + <td></td> + <td class='mw-input'>" . + Xml::checkLabel( + $this->msg( 'import-assign-known-users' )->text(), + 'assignKnownUsers', + 'assignKnownUsers', + $this->assignKnownUsers + ) . + "</td> + </tr> + <tr> + <td class='mw-label'>" . + Xml::label( $this->msg( 'import-comment' )->text(), 'mw-import-comment' ) . + "</td> + <td class='mw-input'>" . + Xml::input( 'log-comment', 50, + ( $this->sourceName === 'upload' ? $this->logcomment : '' ), + [ 'id' => 'mw-import-comment', 'type' => 'text' ] ) . ' ' . + "</td> + </tr> + $mappingSelection + <tr> + <td></td> + <td class='mw-submit'>" . + Xml::submitButton( $this->msg( 'uploadbtn' )->text() ) . + "</td> + </tr>" . + Xml::closeElement( 'table' ) . + Html::hidden( 'editToken', $user->getEditToken() ) . + Xml::closeElement( 'form' ) . + Xml::closeElement( 'fieldset' ) + ); + } else { + if ( empty( $this->importSources ) ) { + $out->addWikiMsg( 'importnosources' ); + } + } + + if ( $user->isAllowed( 'import' ) && !empty( $this->importSources ) ) { + # Show input field for import depth only if $wgExportMaxLinkDepth > 0 + $importDepth = ''; + if ( $this->getConfig()->get( 'ExportMaxLinkDepth' ) > 0 ) { + $importDepth = "<tr> + <td class='mw-label'>" . + $this->msg( 'export-pagelinks' )->parse() . + "</td> + <td class='mw-input'>" . + Xml::input( 'pagelink-depth', 3, 0 ) . + "</td> + </tr>"; + } + $mappingSelection = $this->getMappingFormPart( 'interwiki' ); + + $out->addHTML( + Xml::fieldset( $this->msg( 'importinterwiki' )->text() ) . + Xml::openElement( + 'form', + [ + 'method' => 'post', + 'action' => $action, + 'id' => 'mw-import-interwiki-form' + ] + ) . + $this->msg( 'import-interwiki-text' )->parseAsBlock() . + Html::hidden( 'action', 'submit' ) . + Html::hidden( 'source', 'interwiki' ) . + Html::hidden( 'editToken', $user->getEditToken() ) . + Xml::openElement( 'table', [ 'id' => 'mw-import-table-interwiki' ] ) . + "<tr> + <td class='mw-label'>" . + Xml::label( $this->msg( 'import-interwiki-sourcewiki' )->text(), 'interwiki' ) . + "</td> + <td class='mw-input'>" . + Xml::openElement( + 'select', + [ 'name' => 'interwiki', 'id' => 'interwiki' ] + ) + ); + + $needSubprojectField = false; + foreach ( $this->importSources as $key => $value ) { + if ( is_int( $key ) ) { + $key = $value; + } elseif ( $value !== $key ) { + $needSubprojectField = true; + } + + $attribs = [ + 'value' => $key, + ]; + if ( is_array( $value ) ) { + $attribs['data-subprojects'] = implode( ' ', $value ); + } + if ( $this->interwiki === $key ) { + $attribs['selected'] = 'selected'; + } + $out->addHTML( Html::element( 'option', $attribs, $key ) ); + } + + $out->addHTML( + Xml::closeElement( 'select' ) + ); + + if ( $needSubprojectField ) { + $out->addHTML( + Xml::openElement( + 'select', + [ 'name' => 'subproject', 'id' => 'subproject' ] + ) + ); + + $subprojectsToAdd = []; + foreach ( $this->importSources as $key => $value ) { + if ( is_array( $value ) ) { + $subprojectsToAdd = array_merge( $subprojectsToAdd, $value ); + } + } + $subprojectsToAdd = array_unique( $subprojectsToAdd ); + sort( $subprojectsToAdd ); + foreach ( $subprojectsToAdd as $subproject ) { + $out->addHTML( Xml::option( $subproject, $subproject, $this->subproject === $subproject ) ); + } + + $out->addHTML( + Xml::closeElement( 'select' ) + ); + } + + $out->addHTML( + "</td> + </tr> + <tr> + <td class='mw-label'>" . + Xml::label( $this->msg( 'import-interwiki-sourcepage' )->text(), 'frompage' ) . + "</td> + <td class='mw-input'>" . + Xml::input( 'frompage', 50, $this->frompage, [ 'id' => 'frompage' ] ) . + "</td> + </tr> + <tr> + <td> + </td> + <td class='mw-input'>" . + Xml::checkLabel( + $this->msg( 'import-interwiki-history' )->text(), + 'interwikiHistory', + 'interwikiHistory', + $this->history + ) . + "</td> + </tr> + <tr> + <td> + </td> + <td class='mw-input'>" . + Xml::checkLabel( + $this->msg( 'import-interwiki-templates' )->text(), + 'interwikiTemplates', + 'interwikiTemplates', + $this->includeTemplates + ) . + "</td> + </tr> + <tr> + <td></td> + <td class='mw-input'>" . + Xml::checkLabel( + $this->msg( 'import-assign-known-users' )->text(), + 'assignKnownUsers', + 'assignKnownUsers', + $this->assignKnownUsers + ) . + "</td> + </tr> + $importDepth + <tr> + <td class='mw-label'>" . + Xml::label( $this->msg( 'import-comment' )->text(), 'mw-interwiki-comment' ) . + "</td> + <td class='mw-input'>" . + Xml::input( 'log-comment', 50, + ( $this->sourceName === 'interwiki' ? $this->logcomment : '' ), + [ 'id' => 'mw-interwiki-comment', 'type' => 'text' ] ) . ' ' . + "</td> + </tr> + $mappingSelection + <tr> + <td> + </td> + <td class='mw-submit'>" . + Xml::submitButton( + $this->msg( 'import-interwiki-submit' )->text(), + Linker::tooltipAndAccesskeyAttribs( 'import' ) + ) . + "</td> + </tr>" . + Xml::closeElement( 'table' ) . + Xml::closeElement( 'form' ) . + Xml::closeElement( 'fieldset' ) + ); + } + } + + protected function getGroupName() { + return 'pagetools'; + } +} diff --git a/www/wiki/includes/specials/SpecialJavaScriptTest.php b/www/wiki/includes/specials/SpecialJavaScriptTest.php new file mode 100644 index 00000000..b786c869 --- /dev/null +++ b/www/wiki/includes/specials/SpecialJavaScriptTest.php @@ -0,0 +1,205 @@ +<?php +/** + * Implements Special:JavaScriptTest + * + * 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 SpecialJavaScriptTest extends SpecialPage { + + public function __construct() { + parent::__construct( 'JavaScriptTest' ); + } + + public function execute( $par ) { + $out = $this->getOutput(); + + $this->setHeaders(); + $out->disallowUserJs(); + + // This special page is disabled by default ($wgEnableJavaScriptTest), and contains + // no sensitive data. In order to allow TestSwarm to embed it into a test client window, + // we need to allow iframing of this page. + $out->allowClickjacking(); + + // Sub resource: Internal JavaScript export bundle for QUnit + if ( $par === 'qunit/export' ) { + $this->exportQUnit(); + return; + } + + // Regular view: QUnit test runner + // (Support "/qunit" and "/qunit/plain" for backwards compatibility) + if ( $par === null || $par === '' || $par === 'qunit' || $par === 'qunit/plain' ) { + $this->plainQUnit(); + return; + } + + // Unknown action + $out->setStatusCode( 404 ); + $out->setPageTitle( $this->msg( 'javascripttest' ) ); + $out->addHTML( + '<div class="error">' + . $this->msg( 'javascripttest-pagetext-unknownaction' ) + ->plaintextParams( $par )->parseAsBlock() + . '</div>' + ); + } + + /** + * Get summary text wrapped in a container + * + * @return string HTML + */ + private function getSummaryHtml() { + $summary = $this->msg( 'javascripttest-qunit-intro' ) + ->params( 'https://www.mediawiki.org/wiki/Manual:JavaScript_unit_testing' ) + ->parseAsBlock(); + return "<div id=\"mw-javascripttest-summary\">$summary</div>"; + } + + /** + * Generate self-sufficient JavaScript payload to run the tests elsewhere. + * + * Includes startup module to request modules from ResourceLoader. + * + * Note: This modifies the registry to replace 'jquery.qunit' with an + * empty module to allow external environment to preload QUnit with any + * neccecary framework adapters (e.g. Karma). Loading it again would + * re-define QUnit and dereference event handlers from Karma. + */ + private function exportQUnit() { + $out = $this->getOutput(); + $out->disable(); + + $rl = $out->getResourceLoader(); + + $query = [ + 'lang' => $this->getLanguage()->getCode(), + 'skin' => $this->getSkin()->getSkinName(), + 'debug' => ResourceLoader::inDebugMode() ? 'true' : 'false', + 'target' => 'test', + ]; + $embedContext = new ResourceLoaderContext( $rl, new FauxRequest( $query ) ); + $query['only'] = 'scripts'; + $startupContext = new ResourceLoaderContext( $rl, new FauxRequest( $query ) ); + + $query['raw'] = true; + + $modules = $rl->getTestModuleNames( 'qunit' ); + + // Disable autostart because we load modules asynchronously. By default, QUnit would start + // at domready when there are no tests loaded and also fire 'QUnit.done' which then instructs + // Karma to end the run before the tests even started. + $qunitConfig = 'QUnit.config.autostart = false;' + . 'if (window.__karma__) {' + // karma-qunit's use of autostart=false and QUnit.start conflicts with ours. + // Hack around this by replacing 'karma.loaded' with a no-op and call it ourselves later. + // See <https://github.com/karma-runner/karma-qunit/issues/27>. + . 'window.__karma__.loaded = function () {};' + . '}'; + + // The below is essentially a pure-javascript version of OutputPage::headElement(). + $startup = $rl->makeModuleResponse( $startupContext, [ + 'startup' => $rl->getModule( 'startup' ), + ] ); + // Embed page-specific mw.config variables. + // The current Special page shouldn't be relevant to tests, but various modules (which + // are loaded before the test suites), reference mw.config while initialising. + $code = ResourceLoader::makeConfigSetScript( $out->getJSVars() ); + // Embed private modules as they're not allowed to be loaded dynamically + $code .= $rl->makeModuleResponse( $embedContext, [ + 'user.options' => $rl->getModule( 'user.options' ), + 'user.tokens' => $rl->getModule( 'user.tokens' ), + ] ); + // Catch exceptions (such as "dependency missing" or "unknown module") so that we + // always start QUnit. Re-throw so that they are caught and reported as global exceptions + // by QUnit and Karma. + $modules = Xml::encodeJsVar( $modules ); + $code .= <<<CODE +(function () { + var start = window.__karma__ ? window.__karma__.start : QUnit.start; + try { + mw.loader.using( $modules ) + .always( function () { + start(); + } ) + .fail( function ( e ) { + setTimeout( function () { + throw e; + } ); + } ); + } catch ( e ) { + start(); + throw e; + } +}()); +CODE; + + header( 'Content-Type: text/javascript; charset=utf-8' ); + header( 'Cache-Control: private, no-cache, must-revalidate' ); + header( 'Pragma: no-cache' ); + echo $qunitConfig; + echo $startup; + // The following has to be deferred via RLQ because the startup module is asynchronous. + echo ResourceLoader::makeLoaderConditionalScript( $code ); + } + + private function plainQUnit() { + $out = $this->getOutput(); + $out->disable(); + + $styles = $out->makeResourceLoaderLink( 'jquery.qunit', + ResourceLoaderModule::TYPE_STYLES + ); + + // Use 'raw' because QUnit loads before ResourceLoader initialises (omit mw.loader.state call) + // Use 'test' to ensure OutputPage doesn't use the "async" attribute because QUnit must + // load before qunit/export. + $scripts = $out->makeResourceLoaderLink( 'jquery.qunit', + ResourceLoaderModule::TYPE_SCRIPTS, + [ 'raw' => true, 'sync' => true ] + ); + + $head = implode( "\n", [ $styles, $scripts ] ); + $summary = $this->getSummaryHtml(); + $html = <<<HTML +<!DOCTYPE html> +<title>QUnit</title> +$head +$summary +<div id="qunit"></div> +HTML; + + $url = $this->getPageTitle( 'qunit/export' )->getFullURL( [ + 'debug' => ResourceLoader::inDebugMode() ? 'true' : 'false', + ] ); + $html .= "\n" . Html::linkedScript( $url ); + + header( 'Content-Type: text/html; charset=utf-8' ); + echo $html; + } + + protected function getGroupName() { + return 'other'; + } +} diff --git a/www/wiki/includes/specials/SpecialLinkAccounts.php b/www/wiki/includes/specials/SpecialLinkAccounts.php new file mode 100644 index 00000000..da10b90b --- /dev/null +++ b/www/wiki/includes/specials/SpecialLinkAccounts.php @@ -0,0 +1,111 @@ +<?php + +use MediaWiki\Auth\AuthenticationRequest; +use MediaWiki\Auth\AuthenticationResponse; +use MediaWiki\Auth\AuthManager; + +/** + * Links/unlinks external accounts to the current user. + * + * To interact with this page, account providers need to register themselves with AuthManager. + */ +class SpecialLinkAccounts extends AuthManagerSpecialPage { + protected static $allowedActions = [ + AuthManager::ACTION_LINK, AuthManager::ACTION_LINK_CONTINUE, + ]; + + public function __construct() { + parent::__construct( 'LinkAccounts' ); + } + + protected function getGroupName() { + return 'users'; + } + + public function isListed() { + return AuthManager::singleton()->canLinkAccounts(); + } + + protected function getRequestBlacklist() { + return $this->getConfig()->get( 'ChangeCredentialsBlacklist' ); + } + + /** + * @param null|string $subPage + * @throws MWException + * @throws PermissionsError + */ + public function execute( $subPage ) { + $this->setHeaders(); + $this->loadAuth( $subPage ); + + if ( !$this->isActionAllowed( $this->authAction ) ) { + if ( $this->authAction === AuthManager::ACTION_LINK ) { + // looks like no linking provider is installed or willing to take this user + $titleMessage = wfMessage( 'cannotlink-no-provider-title' ); + $errorMessage = wfMessage( 'cannotlink-no-provider' ); + throw new ErrorPageError( $titleMessage, $errorMessage ); + } else { + // user probably back-button-navigated into an auth session that no longer exists + // FIXME would be nice to show a message + $this->getOutput()->redirect( $this->getPageTitle()->getFullURL( '', false, + PROTO_HTTPS ) ); + return; + } + } + + $this->outputHeader(); + + $status = $this->trySubmit(); + + if ( $status === false || !$status->isOK() ) { + $this->displayForm( $status ); + return; + } + + $response = $status->getValue(); + + switch ( $response->status ) { + case AuthenticationResponse::PASS: + $this->success(); + break; + case AuthenticationResponse::FAIL: + $this->loadAuth( '', AuthManager::ACTION_LINK, true ); + $this->displayForm( StatusValue::newFatal( $response->message ) ); + break; + case AuthenticationResponse::REDIRECT: + $this->getOutput()->redirect( $response->redirectTarget ); + break; + case AuthenticationResponse::UI: + $this->authAction = AuthManager::ACTION_LINK_CONTINUE; + $this->authRequests = $response->neededRequests; + $this->displayForm( StatusValue::newFatal( $response->message ) ); + break; + default: + throw new LogicException( 'invalid AuthenticationResponse' ); + } + } + + protected function getDefaultAction( $subPage ) { + return AuthManager::ACTION_LINK; + } + + /** + * @param AuthenticationRequest[] $requests + * @param string $action AuthManager action name, should be ACTION_LINK or ACTION_LINK_CONTINUE + * @return HTMLForm + */ + protected function getAuthForm( array $requests, $action ) { + $form = parent::getAuthForm( $requests, $action ); + $form->setSubmitTextMsg( 'linkaccounts-submit' ); + return $form; + } + + /** + * Show a success message. + */ + protected function success() { + $this->loadAuth( '', AuthManager::ACTION_LINK, true ); + $this->displayForm( StatusValue::newFatal( $this->msg( 'linkaccounts-success-text' ) ) ); + } +} diff --git a/www/wiki/includes/specials/SpecialLinkSearch.php b/www/wiki/includes/specials/SpecialLinkSearch.php new file mode 100644 index 00000000..ef952543 --- /dev/null +++ b/www/wiki/includes/specials/SpecialLinkSearch.php @@ -0,0 +1,274 @@ +<?php +/** + * Implements Special:LinkSearch + * + * 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 + * @author Brion Vibber + */ + +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * Special:LinkSearch to search the external-links table. + * @ingroup SpecialPage + */ +class LinkSearchPage extends QueryPage { + /** @var array|bool */ + private $mungedQuery = false; + + function setParams( $params ) { + $this->mQuery = $params['query']; + $this->mNs = $params['namespace']; + $this->mProt = $params['protocol']; + } + + function __construct( $name = 'LinkSearch' ) { + parent::__construct( $name ); + + // Since we don't control the constructor parameters, we can't inject services that way. + // Instead, we initialize services in the execute() method, and allow them to be overridden + // using the setServices() method. + } + + function isCacheable() { + return false; + } + + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + + $out = $this->getOutput(); + $out->allowClickjacking(); + + $request = $this->getRequest(); + $target = $request->getVal( 'target', $par ); + $namespace = $request->getIntOrNull( 'namespace' ); + + $protocols_list = []; + foreach ( $this->getConfig()->get( 'UrlProtocols' ) as $prot ) { + if ( $prot !== '//' ) { + $protocols_list[] = $prot; + } + } + + $target2 = $target; + // Get protocol, default is http:// + $protocol = 'http://'; + $bits = wfParseUrl( $target ); + if ( isset( $bits['scheme'] ) && isset( $bits['delimiter'] ) ) { + $protocol = $bits['scheme'] . $bits['delimiter']; + // Make sure wfParseUrl() didn't make some well-intended correction in the + // protocol + if ( strcasecmp( $protocol, substr( $target, 0, strlen( $protocol ) ) ) === 0 ) { + $target2 = substr( $target, strlen( $protocol ) ); + } else { + // If it did, let LinkFilter::makeLikeArray() handle this + $protocol = ''; + } + } + + $out->addWikiMsg( + 'linksearch-text', + '<nowiki>' . $this->getLanguage()->commaList( $protocols_list ) . '</nowiki>', + count( $protocols_list ) + ); + $fields = [ + 'target' => [ + 'type' => 'text', + 'name' => 'target', + 'id' => 'target', + 'size' => 50, + 'label-message' => 'linksearch-pat', + 'default' => $target, + 'dir' => 'ltr', + ] + ]; + if ( !$this->getConfig()->get( 'MiserMode' ) ) { + $fields += [ + 'namespace' => [ + 'type' => 'namespaceselect', + 'name' => 'namespace', + 'label-message' => 'linksearch-ns', + 'default' => $namespace, + 'id' => 'namespace', + 'all' => '', + 'cssclass' => 'namespaceselector', + ], + ]; + } + $hiddenFields = [ + 'title' => $this->getPageTitle()->getPrefixedDBkey(), + ]; + $htmlForm = HTMLForm::factory( 'ooui', $fields, $this->getContext() ); + $htmlForm->addHiddenFields( $hiddenFields ); + $htmlForm->setSubmitTextMsg( 'linksearch-ok' ); + $htmlForm->setWrapperLegendMsg( 'linksearch' ); + $htmlForm->setAction( wfScript() ); + $htmlForm->setMethod( 'get' ); + $htmlForm->prepareForm()->displayForm( false ); + $this->addHelpLink( 'Help:Linksearch' ); + + if ( $target != '' ) { + $this->setParams( [ + 'query' => Parser::normalizeLinkUrl( $target2 ), + 'namespace' => $namespace, + 'protocol' => $protocol ] ); + parent::execute( $par ); + if ( $this->mungedQuery === false ) { + $out->addWikiMsg( 'linksearch-error' ); + } + } + } + + /** + * Disable RSS/Atom feeds + * @return bool + */ + function isSyndicated() { + return false; + } + + /** + * Return an appropriately formatted LIKE query and the clause + * + * @param string $query Search pattern to search for + * @param string $prot Protocol, e.g. 'http://' + * + * @return array + */ + static function mungeQuery( $query, $prot ) { + $field = 'el_index'; + $dbr = wfGetDB( DB_REPLICA ); + + if ( $query === '*' && $prot !== '' ) { + // Allow queries like 'ftp://*' to find all ftp links + $rv = [ $prot, $dbr->anyString() ]; + } else { + $rv = LinkFilter::makeLikeArray( $query, $prot ); + } + + if ( $rv === false ) { + // LinkFilter doesn't handle wildcard in IP, so we'll have to munge here. + $pattern = '/^(:?[0-9]{1,3}\.)+\*\s*$|^(:?[0-9]{1,3}\.){3}[0-9]{1,3}:[0-9]*\*\s*$/'; + if ( preg_match( $pattern, $query ) ) { + $rv = [ $prot . rtrim( $query, " \t*" ), $dbr->anyString() ]; + $field = 'el_to'; + } + } + + return [ $rv, $field ]; + } + + function linkParameters() { + $params = []; + $params['target'] = $this->mProt . $this->mQuery; + if ( $this->mNs !== null && !$this->getConfig()->get( 'MiserMode' ) ) { + $params['namespace'] = $this->mNs; + } + + return $params; + } + + public function getQueryInfo() { + $dbr = wfGetDB( DB_REPLICA ); + // strip everything past first wildcard, so that + // index-based-only lookup would be done + list( $this->mungedQuery, $clause ) = self::mungeQuery( $this->mQuery, $this->mProt ); + if ( $this->mungedQuery === false ) { + // Invalid query; return no results + return [ 'tables' => 'page', 'fields' => 'page_id', 'conds' => '0=1' ]; + } + + $stripped = LinkFilter::keepOneWildcard( $this->mungedQuery ); + $like = $dbr->buildLike( $stripped ); + $retval = [ + 'tables' => [ 'page', 'externallinks' ], + 'fields' => [ + 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'el_index', + 'url' => 'el_to' + ], + 'conds' => [ + 'page_id = el_from', + "$clause $like" + ], + 'options' => [ 'USE INDEX' => $clause ] + ]; + + if ( $this->mNs !== null && !$this->getConfig()->get( 'MiserMode' ) ) { + $retval['conds']['page_namespace'] = $this->mNs; + } + + return $retval; + } + + /** + * Pre-fill the link cache + * + * @param IDatabase $db + * @param IResultWrapper $res + */ + function preprocessResults( $db, $res ) { + $this->executeLBFromResultWrapper( $res ); + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + $title = new TitleValue( (int)$result->namespace, $result->title ); + $pageLink = $this->getLinkRenderer()->makeLink( $title ); + + $url = $result->url; + $urlLink = Linker::makeExternalLink( $url, $url ); + + return $this->msg( 'linksearch-line' )->rawParams( $urlLink, $pageLink )->escaped(); + } + + /** + * Override to squash the ORDER BY. + * We do a truncated index search, so the optimizer won't trust + * it as good enough for optimizing sort. The implicit ordering + * from the scan will usually do well enough for our needs. + * @return array + */ + function getOrderFields() { + return []; + } + + protected function getGroupName() { + return 'redirects'; + } + + /** + * enwiki complained about low limits on this special page + * + * @see T130058 + * @todo FIXME This special page should not use LIMIT for paging + * @return int + */ + protected function getMaxResults() { + return max( parent::getMaxResults(), 60000 ); + } +} diff --git a/www/wiki/includes/specials/SpecialListDuplicatedFiles.php b/www/wiki/includes/specials/SpecialListDuplicatedFiles.php new file mode 100644 index 00000000..4c847e9e --- /dev/null +++ b/www/wiki/includes/specials/SpecialListDuplicatedFiles.php @@ -0,0 +1,106 @@ +<?php +/** + * Implements Special:ListDuplicatedFiles + * + * Copyright © 2013 Brian Wolff + * + * 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 + * @author Brian Wolff + */ + +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * Special:ListDuplicatedFiles Lists all files where the current version is + * a duplicate of the current version of some other file. + * @ingroup SpecialPage + */ +class ListDuplicatedFilesPage extends QueryPage { + function __construct( $name = 'ListDuplicatedFiles' ) { + parent::__construct( $name ); + } + + public function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + /** + * Get all the duplicates by grouping on sha1s. + * + * A cheaper (but less useful) version of this + * query would be to not care how many duplicates a + * particular file has, and do a self-join on image table. + * However this version should be no more expensive then + * Special:MostLinked, which seems to get handled fine + * with however we are doing cached special pages. + * @return array + */ + public function getQueryInfo() { + return [ + 'tables' => [ 'image' ], + 'fields' => [ + 'namespace' => NS_FILE, + 'title' => 'MIN(img_name)', + 'value' => 'count(*)' + ], + 'options' => [ + 'GROUP BY' => 'img_sha1', + 'HAVING' => 'count(*) > 1', + ], + ]; + } + + /** + * Pre-fill the link cache + * + * @param IDatabase $db + * @param IResultWrapper $res + */ + function preprocessResults( $db, $res ) { + $this->executeLBFromResultWrapper( $res ); + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + // Future version might include a list of the first 5 duplicates + // perhaps separated by an "↔". + $image1 = Title::makeTitle( $result->namespace, $result->title ); + $dupeSearch = SpecialPage::getTitleFor( 'FileDuplicateSearch', $image1->getDBkey() ); + + $msg = $this->msg( 'listduplicatedfiles-entry' ) + ->params( $image1->getText() ) + ->numParams( $result->value - 1 ) + ->params( $dupeSearch->getPrefixedDBkey() ); + + return $msg->parse(); + } + + protected function getGroupName() { + return 'media'; + } +} diff --git a/www/wiki/includes/specials/SpecialListfiles.php b/www/wiki/includes/specials/SpecialListfiles.php new file mode 100644 index 00000000..e6e1048c --- /dev/null +++ b/www/wiki/includes/specials/SpecialListfiles.php @@ -0,0 +1,83 @@ +<?php +/** + * Implements Special:Listfiles + * + * 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 + */ + +class SpecialListFiles extends IncludableSpecialPage { + public function __construct() { + parent::__construct( 'Listfiles' ); + } + + public function execute( $par ) { + $this->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/www/wiki/includes/specials/SpecialListgrants.php b/www/wiki/includes/specials/SpecialListgrants.php new file mode 100644 index 00000000..1a04eec4 --- /dev/null +++ b/www/wiki/includes/specials/SpecialListgrants.php @@ -0,0 +1,91 @@ +<?php +/** + * Implements Special:Listgrants + * + * 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 + */ + +/** + * This special page lists all defined rights grants and the associated rights. + * See also @ref $wgGrantPermissions and @ref $wgGrantPermissionGroups. + * + * @ingroup SpecialPage + */ +class SpecialListGrants extends SpecialPage { + function __construct() { + parent::__construct( 'Listgrants' ); + } + + /** + * Show the special page + * @param string|null $par + */ + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + + $out = $this->getOutput(); + $out->addModuleStyles( 'mediawiki.special' ); + + $out->addHTML( + \Html::openElement( 'table', + [ 'class' => 'wikitable mw-listgrouprights-table' ] ) . + '<tr>' . + \Html::element( 'th', null, $this->msg( 'listgrants-grant' )->text() ) . + \Html::element( 'th', null, $this->msg( 'listgrants-rights' )->text() ) . + '</tr>' + ); + + 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 ), + '<span class="mw-listgrants-right-name">' . $permission . '</span>' + )->parse(); + } + if ( !count( $descs ) ) { + $grantCellHtml = ''; + } else { + sort( $descs ); + $grantCellHtml = '<ul><li>' . implode( "</li>\n<li>", $descs ) . '</li></ul>'; + } + + $id = Sanitizer::escapeIdForAttribute( $grant ); + $out->addHTML( \Html::rawElement( 'tr', [ 'id' => $id ], + "<td>" . + $this->msg( + "listgrants-grant-display", + \User::getGrantName( $grant ), + "<span class='mw-listgrants-grant-name'>" . $id . "</span>" + )->parse() . + "</td>" . + "<td>" . $grantCellHtml . "</td>" + ) ); + } + + $out->addHTML( \Html::closeElement( 'table' ) ); + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/www/wiki/includes/specials/SpecialListgrouprights.php b/www/wiki/includes/specials/SpecialListgrouprights.php new file mode 100644 index 00000000..cc62d614 --- /dev/null +++ b/www/wiki/includes/specials/SpecialListgrouprights.php @@ -0,0 +1,294 @@ +<?php +/** + * Implements Special:Listgrouprights + * + * 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 + */ + +/** + * This special page lists all defined user groups and the associated rights. + * See also @ref $wgGroupPermissions. + * + * @ingroup SpecialPage + * @author Petr Kadlec <mormegil@centrum.cz> + */ +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( "<div class=\"mw-listgrouprights-key\">\n$1\n</div>", 'listgrouprights-key' ); + + $out->addHTML( + Xml::openElement( 'table', [ 'class' => 'wikitable mw-listgrouprights-table' ] ) . + '<tr>' . + Xml::element( 'th', null, $this->msg( 'listgrouprights-group' )->text() ) . + Xml::element( 'th', null, $this->msg( 'listgrouprights-rights' )->text() ) . + '</tr>' + ); + + $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 = isset( $groupPermissions[$group] ) + ? $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 = '<br />' . $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Listusers' ), + $this->msg( 'listgrouprights-members' )->text() + ); + } elseif ( !in_array( $group, $config->get( 'ImplicitGroups' ) ) ) { + $grouplink = '<br />' . $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 = isset( $revokePermissions[$group] ) ? $revokePermissions[$group] : []; + $addgroups = isset( $addGroups[$group] ) ? $addGroups[$group] : []; + $removegroups = isset( $removeGroups[$group] ) ? $removeGroups[$group] : []; + $addgroupsSelf = isset( $groupsAddToSelf[$group] ) ? $groupsAddToSelf[$group] : []; + $removegroupsSelf = isset( $groupsRemoveFromSelf[$group] ) + ? $groupsRemoveFromSelf[$group] + : []; + + $id = $group == '*' ? false : Sanitizer::escapeIdForAttribute( $group ); + $out->addHTML( Html::rawElement( 'tr', [ 'id' => $id ], " + <td>$grouppage$grouplink</td> + <td>" . + $this->formatPermissions( $permissions, $revoke, $addgroups, $removegroups, + $addgroupsSelf, $removegroupsSelf ) . + '</td> + ' + ) ); + } + $out->addHTML( Xml::closeElement( 'table' ) ); + $this->outputNamespaceProtectionInfo(); + } + + private function outputNamespaceProtectionInfo() { + global $wgContLang; + $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 ); + foreach ( $namespaceProtection as $namespace => $rights ) { + if ( !in_array( $namespace, MWNamespace::getValidNamespaces() ) ) { + continue; + } + + if ( $namespace == NS_MAIN ) { + $namespaceText = $this->msg( 'blanknamespace' )->text(); + } else { + $namespaceText = $wgContLang->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 ), + '<span class="mw-listgrouprights-right-name">' . $permission . '</span>' + )->parse(); + } + } + foreach ( $revoke as $permission => $revoked ) { + if ( $revoked ) { + $r[] = $this->msg( 'listgrouprights-right-revoked', + User::getRightDescription( $permission ), + '<span class="mw-listgrouprights-right-name">' . $permission . '</span>' + )->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 '<ul><li>' . implode( "</li>\n<li>", $r ) . '</li></ul>'; + } + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/www/wiki/includes/specials/SpecialListredirects.php b/www/wiki/includes/specials/SpecialListredirects.php new file mode 100644 index 00000000..48f36402 --- /dev/null +++ b/www/wiki/includes/specials/SpecialListredirects.php @@ -0,0 +1,151 @@ +<?php +/** + * Implements Special:Listredirects + * + * Copyright © 2006 Rob Church + * + * 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 + * @author Rob Church <robchur@gmail.com> + */ + +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * Special:Listredirects - Lists all the redirects on the wiki. + * @ingroup SpecialPage + */ +class ListredirectsPage extends QueryPage { + function __construct( $name = 'Listredirects' ) { + parent::__construct( $name ); + } + + public function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + function sortDescending() { + return false; + } + + public function getQueryInfo() { + return [ + 'tables' => [ 'p1' => 'page', 'redirect', 'p2' => 'page' ], + 'fields' => [ 'namespace' => 'p1.page_namespace', + 'title' => 'p1.page_title', + 'value' => 'p1.page_title', + 'rd_namespace', + 'rd_title', + 'rd_fragment', + 'rd_interwiki', + 'redirid' => 'p2.page_id' ], + 'conds' => [ 'p1.page_is_redirect' => 1 ], + 'join_conds' => [ 'redirect' => [ + 'LEFT JOIN', 'rd_from=p1.page_id' ], + 'p2' => [ 'LEFT JOIN', [ + 'p2.page_namespace=rd_namespace', + 'p2.page_title=rd_title' ] ] ] + ]; + } + + function getOrderFields() { + return [ 'p1.page_namespace', 'p1.page_title' ]; + } + + /** + * Cache page existence for performance + * + * @param IDatabase $db + * @param IResultWrapper $res + */ + function preprocessResults( $db, $res ) { + if ( !$res->numRows() ) { + return; + } + + $batch = new LinkBatch; + foreach ( $res as $row ) { + $batch->add( $row->namespace, $row->title ); + $redirTarget = $this->getRedirectTarget( $row ); + if ( $redirTarget ) { + $batch->addObj( $redirTarget ); + } + } + $batch->execute(); + + // Back to start for display + $res->seek( 0 ); + } + + /** + * @param stdClass $row + * @return Title|null + */ + protected function getRedirectTarget( $row ) { + if ( isset( $row->rd_title ) ) { + return Title::makeTitle( $row->rd_namespace, + $row->rd_title, $row->rd_fragment, + $row->rd_interwiki + ); + } else { + $title = Title::makeTitle( $row->namespace, $row->title ); + $article = WikiPage::factory( $title ); + + return $article->getRedirectTarget(); + } + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + $linkRenderer = $this->getLinkRenderer(); + # Make a link to the redirect itself + $rd_title = Title::makeTitle( $result->namespace, $result->title ); + $rd_link = $linkRenderer->makeLink( + $rd_title, + null, + [], + [ 'redirect' => 'no' ] + ); + + # Find out where the redirect leads + $target = $this->getRedirectTarget( $result ); + if ( $target ) { + # Make a link to the destination page + $lang = $this->getLanguage(); + $arr = $lang->getArrow() . $lang->getDirMark(); + $targetLink = $linkRenderer->makeLink( $target, $target->getFullText() ); + + return "$rd_link $arr $targetLink"; + } else { + return "<del>$rd_link</del>"; + } + } + + protected function getGroupName() { + return 'pages'; + } +} diff --git a/www/wiki/includes/specials/SpecialListusers.php b/www/wiki/includes/specials/SpecialListusers.php new file mode 100644 index 00000000..dee2968d --- /dev/null +++ b/www/wiki/includes/specials/SpecialListusers.php @@ -0,0 +1,101 @@ +<?php +/** + * Implements Special:Listusers + * + * Copyright © 2004 Brion Vibber, lcrocker, Tim Starling, + * Domas Mituzas, Antoine Musso, Jens Frank, Zhengzhu, + * 2006 Rob Church <robchur@gmail.com> + * + * 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' ); + } + + /** + * Show the special page + * + * @param string $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'; + } +} + +/** + * Redirect page: Special:ListAdmins --> Special:ListUsers/sysop. + * + * @ingroup SpecialPage + */ +class SpecialListAdmins extends SpecialRedirectToSpecial { + function __construct() { + parent::__construct( 'Listadmins', 'Listusers', 'sysop' ); + } +} + +/** + * Redirect page: Special:ListBots --> Special:ListUsers/bot. + * + * @ingroup SpecialPage + */ +class SpecialListBots extends SpecialRedirectToSpecial { + function __construct() { + parent::__construct( 'Listbots', 'Listusers', 'bot' ); + } +} diff --git a/www/wiki/includes/specials/SpecialLockdb.php b/www/wiki/includes/specials/SpecialLockdb.php new file mode 100644 index 00000000..fb04b90b --- /dev/null +++ b/www/wiki/includes/specials/SpecialLockdb.php @@ -0,0 +1,118 @@ +<?php +/** + * Implements Special:Lockdb + * + * 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 + */ + +/** + * A form to make the database readonly (eg for maintenance purposes). + * + * @ingroup SpecialPage + */ +class SpecialLockdb extends FormSpecialPage { + protected $reason = ''; + + public function __construct() { + parent::__construct( 'Lockdb', 'siteadmin' ); + } + + public function doesWrites() { + return false; + } + + public function requiresWrite() { + return false; + } + + public function checkExecutePermissions( User $user ) { + parent::checkExecutePermissions( $user ); + # If the lock file isn't writable, we can do sweet bugger all + if ( !is_writable( dirname( $this->getConfig()->get( 'ReadOnlyFile' ) ) ) ) { + throw new ErrorPageError( 'lockdb', 'lockfilenotwritable' ); + } + if ( file_exists( $this->getConfig()->get( 'ReadOnlyFile' ) ) ) { + throw new ErrorPageError( 'lockdb', 'databaselocked' ); + } + } + + protected function getFormFields() { + return [ + 'Reason' => [ + 'type' => 'textarea', + 'rows' => 4, + 'vertical-label' => true, + 'label-message' => 'enterlockreason', + ], + 'Confirm' => [ + 'type' => 'toggle', + 'label-message' => 'lockconfirm', + ], + ]; + } + + protected function alterForm( HTMLForm $form ) { + $form->setWrapperLegend( false ) + ->setHeaderText( $this->msg( 'lockdbtext' )->parseAsBlock() ) + ->setSubmitTextMsg( 'lockbtn' ); + } + + public function onSubmit( array $data ) { + global $wgContLang; + + if ( !$data['Confirm'] ) { + return Status::newFatal( 'locknoconfirm' ); + } + + Wikimedia\suppressWarnings(); + $fp = fopen( $this->getConfig()->get( 'ReadOnlyFile' ), 'w' ); + Wikimedia\restoreWarnings(); + + if ( false === $fp ) { + # This used to show a file not found error, but the likeliest reason for fopen() + # to fail at this point is insufficient permission to write to the file...good old + # is_writable() is plain wrong in some cases, it seems... + return Status::newFatal( 'lockfilenotwritable' ); + } + fwrite( $fp, $data['Reason'] ); + $timestamp = wfTimestampNow(); + fwrite( $fp, "\n<p>" . $this->msg( 'lockedbyandtime', + $this->getUser()->getName(), + $wgContLang->date( $timestamp, false, false ), + $wgContLang->time( $timestamp, false, false ) + )->inContentLanguage()->text() . "</p>\n" ); + fclose( $fp ); + + return Status::newGood(); + } + + public function onSuccess() { + $out = $this->getOutput(); + $out->addSubtitle( $this->msg( 'lockdbsuccesssub' ) ); + $out->addWikiMsg( 'lockdbsuccesstext' ); + } + + protected function getDisplayFormat() { + return 'ooui'; + } + + protected function getGroupName() { + return 'wiki'; + } +} diff --git a/www/wiki/includes/specials/SpecialLog.php b/www/wiki/includes/specials/SpecialLog.php new file mode 100644 index 00000000..bad17466 --- /dev/null +++ b/www/wiki/includes/specials/SpecialLog.php @@ -0,0 +1,327 @@ +<?php +/** + * Implements Special:Log + * + * 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 + */ + +/** + * A special page that lists log entries + * + * @ingroup SpecialPage + */ +class SpecialLog extends SpecialPage { + public function __construct() { + parent::__construct( 'Log' ); + } + + public function execute( $par ) { + global $wgActorTableSchemaMigrationStage; + + $this->setHeaders(); + $this->outputHeader(); + $this->getOutput()->addModules( 'mediawiki.userSuggest' ); + $this->addHelpLink( 'Help:Log' ); + + $opts = new FormOptions; + $opts->add( 'type', '' ); + $opts->add( 'user', '' ); + $opts->add( 'page', '' ); + $opts->add( 'pattern', false ); + $opts->add( 'year', null, FormOptions::INTNULL ); + $opts->add( 'month', null, FormOptions::INTNULL ); + $opts->add( 'tagfilter', '' ); + $opts->add( 'offset', '' ); + $opts->add( 'dir', '' ); + $opts->add( 'offender', '' ); + $opts->add( 'subtype', '' ); + $opts->add( 'logid', '' ); + + // Set values + $opts->fetchValuesFromRequest( $this->getRequest() ); + if ( $par !== null ) { + $this->parseParams( $opts, (string)$par ); + } + + # Don't let the user get stuck with a certain date + if ( $opts->getValue( 'offset' ) || $opts->getValue( 'dir' ) == 'prev' ) { + $opts->setValue( 'year', '' ); + $opts->setValue( 'month', '' ); + } + + // If the user doesn't have the right permission to view the specific + // log type, throw a PermissionsError + // If the log type is invalid, just show all public logs + $logRestrictions = $this->getConfig()->get( 'LogRestrictions' ); + $type = $opts->getValue( 'type' ); + if ( !LogPage::isLogType( $type ) ) { + $opts->setValue( 'type', '' ); + } elseif ( isset( $logRestrictions[$type] ) + && !$this->getUser()->isAllowed( $logRestrictions[$type] ) + ) { + throw new PermissionsError( $logRestrictions[$type] ); + } + + # Handle type-specific inputs + $qc = []; + if ( $opts->getValue( 'type' ) == 'suppress' ) { + $offenderName = $opts->getValue( 'offender' ); + $offender = empty( $offenderName ) ? null : User::newFromName( $offenderName, false ); + if ( $offender ) { + if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) { + $qc = [ 'ls_field' => 'target_author_actor', 'ls_value' => $offender->getActorId() ]; + } else { + if ( $offender->getId() > 0 ) { + $field = 'target_author_id'; + $value = $offender->getId(); + } else { + $field = 'target_author_ip'; + $value = $offender->getName(); + } + if ( !$offender->getActorId() ) { + $qc = [ 'ls_field' => $field, 'ls_value' => $value ]; + } else { + $db = wfGetDB( DB_REPLICA ); + $qc = [ + 'ls_field' => [ 'target_author_actor', $field ], // So LogPager::getQueryInfo() works right + $db->makeList( [ + $db->makeList( + [ 'ls_field' => 'target_author_actor', 'ls_value' => $offender->getActorId() ], LIST_AND + ), + $db->makeList( [ 'ls_field' => $field, 'ls_value' => $value ], LIST_AND ), + ], LIST_OR ), + ]; + } + } + } + } else { + // Allow extensions to add relations to their search types + Hooks::run( + 'SpecialLogAddLogSearchRelations', + [ $opts->getValue( 'type' ), $this->getRequest(), &$qc ] + ); + } + + # Some log types are only for a 'User:' title but we might have been given + # only the username instead of the full title 'User:username'. This part try + # to lookup for a user by that name and eventually fix user input. See T3697. + if ( in_array( $opts->getValue( 'type' ), self::getLogTypesOnUser() ) ) { + # ok we have a type of log which expect a user title. + $target = Title::newFromText( $opts->getValue( 'page' ) ); + if ( $target && $target->getNamespace() === NS_MAIN ) { + # User forgot to add 'User:', we are adding it for him + $opts->setValue( 'page', + Title::makeTitleSafe( NS_USER, $opts->getValue( 'page' ) ) + ); + } + } + + $this->show( $opts, $qc ); + } + + /** + * List log type for which the target is a user + * Thus if the given target is in NS_MAIN we can alter it to be an NS_USER + * Title user instead. + * + * @since 1.25 + * @return array + */ + public static function getLogTypesOnUser() { + static $types = null; + if ( $types !== null ) { + return $types; + } + $types = [ + 'block', + 'newusers', + 'rights', + ]; + + Hooks::run( 'GetLogTypesOnUser', [ &$types ] ); + return $types; + } + + /** + * Return an array of subpages that this special page will accept. + * + * @return string[] subpages + */ + public function getSubpagesForPrefixSearch() { + $subpages = $this->getConfig()->get( 'LogTypes' ); + $subpages[] = 'all'; + sort( $subpages ); + return $subpages; + } + + /** + * Set options based on the subpage title parts: + * - One part that is a valid log type: Special:Log/logtype + * - Two parts: Special:Log/logtype/username + * - Otherwise, assume the whole subpage is a username. + * + * @param FormOptions $opts + * @param $par + * @throws ConfigException + */ + private function parseParams( FormOptions $opts, $par ) { + # Get parameters + $par = $par !== null ? $par : ''; + $parms = explode( '/', $par ); + $symsForAll = [ '*', 'all' ]; + if ( $parms[0] != '' && + ( in_array( $par, $this->getConfig()->get( 'LogTypes' ) ) || in_array( $par, $symsForAll ) ) + ) { + $opts->setValue( 'type', $par ); + } elseif ( count( $parms ) == 2 ) { + $opts->setValue( 'type', $parms[0] ); + $opts->setValue( 'user', $parms[1] ); + } elseif ( $par != '' ) { + $opts->setValue( 'user', $par ); + } + } + + private function show( FormOptions $opts, array $extraConds ) { + # Create a LogPager item to get the results and a LogEventsList item to format them... + $loglist = new LogEventsList( + $this->getContext(), + $this->getLinkRenderer(), + LogEventsList::USE_CHECKBOXES + ); + + $pager = new LogPager( + $loglist, + $opts->getValue( 'type' ), + $opts->getValue( 'user' ), + $opts->getValue( 'page' ), + $opts->getValue( 'pattern' ), + $extraConds, + $opts->getValue( 'year' ), + $opts->getValue( 'month' ), + $opts->getValue( 'tagfilter' ), + $opts->getValue( 'subtype' ), + $opts->getValue( 'logid' ) + ); + + $this->addHeader( $opts->getValue( 'type' ) ); + + # Set relevant user + if ( $pager->getPerformer() ) { + $performerUser = User::newFromName( $pager->getPerformer(), false ); + $this->getSkin()->setRelevantUser( $performerUser ); + } + + # Show form options + $loglist->showOptions( + $pager->getType(), + $pager->getPerformer(), + $pager->getPage(), + $pager->getPattern(), + $pager->getYear(), + $pager->getMonth(), + $pager->getFilterParams(), + $pager->getTagFilter(), + $pager->getAction() + ); + + # Insert list + $logBody = $pager->getBody(); + if ( $logBody ) { + $this->getOutput()->addHTML( + $pager->getNavigationBar() . + $this->getActionButtons( + $loglist->beginLogEventsList() . + $logBody . + $loglist->endLogEventsList() + ) . + $pager->getNavigationBar() + ); + } else { + $this->getOutput()->addWikiMsg( 'logempty' ); + } + } + + private function getActionButtons( $formcontents ) { + $user = $this->getUser(); + $canRevDelete = $user->isAllowedAll( 'deletedhistory', 'deletelogentry' ); + $showTagEditUI = ChangeTags::showTagEditingUI( $user ); + # If the user doesn't have the ability to delete log entries nor edit tags, + # don't bother showing them the button(s). + if ( !$canRevDelete && !$showTagEditUI ) { + return $formcontents; + } + + # Show button to hide log entries and/or edit change tags + $s = Html::openElement( + 'form', + [ 'action' => wfScript(), 'id' => 'mw-log-deleterevision-submit' ] + ) . "\n"; + $s .= Html::hidden( 'action', 'historysubmit' ) . "\n"; + $s .= Html::hidden( 'type', 'logging' ) . "\n"; + + $buttons = ''; + if ( $canRevDelete ) { + $buttons .= Html::element( + 'button', + [ + 'type' => 'submit', + 'name' => 'revisiondelete', + 'value' => '1', + 'class' => "deleterevision-log-submit mw-log-deleterevision-button" + ], + $this->msg( 'showhideselectedlogentries' )->text() + ) . "\n"; + } + if ( $showTagEditUI ) { + $buttons .= Html::element( + 'button', + [ + 'type' => 'submit', + 'name' => 'editchangetags', + 'value' => '1', + 'class' => "editchangetags-log-submit mw-log-editchangetags-button" + ], + $this->msg( 'log-edit-tags' )->text() + ) . "\n"; + } + + $buttons .= ( new ListToggle( $this->getOutput() ) )->getHTML(); + + $s .= $buttons . $formcontents . $buttons; + $s .= Html::closeElement( 'form' ); + + return $s; + } + + /** + * Set page title and show header for this log type + * @param string $type + * @since 1.19 + */ + protected function addHeader( $type ) { + $page = new LogPage( $type ); + $this->getOutput()->setPageTitle( $page->getName() ); + $this->getOutput()->addHTML( $page->getDescription() + ->setContext( $this->getContext() )->parseAsBlock() ); + } + + protected function getGroupName() { + return 'changes'; + } +} diff --git a/www/wiki/includes/specials/SpecialLonelypages.php b/www/wiki/includes/specials/SpecialLonelypages.php new file mode 100644 index 00000000..ff76a4b4 --- /dev/null +++ b/www/wiki/includes/specials/SpecialLonelypages.php @@ -0,0 +1,102 @@ +<?php +/** + * Implements Special:Lonelypaages + * + * 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 + */ + +/** + * A special page looking for articles with no article linking to them, + * thus being lonely. + * + * @ingroup SpecialPage + */ +class LonelyPagesPage extends PageQueryPage { + function __construct( $name = 'Lonelypages' ) { + parent::__construct( $name ); + } + + function getPageHeader() { + return $this->msg( 'lonelypagestext' )->parseAsBlock(); + } + + function sortDescending() { + return false; + } + + function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + function getQueryInfo() { + $tables = [ 'page', 'pagelinks', 'templatelinks' ]; + $conds = [ + 'pl_namespace IS NULL', + 'page_namespace' => MWNamespace::getContentNamespaces(), + 'page_is_redirect' => 0, + 'tl_namespace IS NULL' + ]; + $joinConds = [ + 'pagelinks' => [ + 'LEFT JOIN', [ + 'pl_namespace = page_namespace', + 'pl_title = page_title' + ] + ], + 'templatelinks' => [ + 'LEFT JOIN', [ + 'tl_namespace = page_namespace', + 'tl_title = page_title' + ] + ] + ]; + + // Allow extensions to modify the query + Hooks::run( 'LonelyPagesQuery', [ &$tables, &$conds, &$joinConds ] ); + + return [ + 'tables' => $tables, + 'fields' => [ + 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'page_title' + ], + 'conds' => $conds, + 'join_conds' => $joinConds + ]; + } + + function getOrderFields() { + // For some crazy reason ordering by a constant + // causes a filesort in MySQL 5 + if ( count( MWNamespace::getContentNamespaces() ) > 1 ) { + return [ 'page_namespace', 'page_title' ]; + } else { + return [ 'page_title' ]; + } + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialLongpages.php b/www/wiki/includes/specials/SpecialLongpages.php new file mode 100644 index 00000000..d90d2718 --- /dev/null +++ b/www/wiki/includes/specials/SpecialLongpages.php @@ -0,0 +1,40 @@ +<?php +/** + * Implements Special:Longpages + * + * 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 LongPagesPage extends ShortPagesPage { + function __construct( $name = 'Longpages' ) { + parent::__construct( $name ); + } + + function sortDescending() { + return true; + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialMIMEsearch.php b/www/wiki/includes/specials/SpecialMIMEsearch.php new file mode 100644 index 00000000..a54d72de --- /dev/null +++ b/www/wiki/includes/specials/SpecialMIMEsearch.php @@ -0,0 +1,241 @@ +<?php +/** + * Implements Special:MIMESearch + * + * 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 + * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com> + */ + +/** + * Searches the database for files of the requested MIME type, comparing this with the + * 'img_major_mime' and 'img_minor_mime' fields in the image table. + * @ingroup SpecialPage + */ +class MIMEsearchPage extends QueryPage { + protected $major, $minor, $mime; + + function __construct( $name = 'MIMEsearch' ) { + parent::__construct( $name ); + } + + public function isExpensive() { + return false; + } + + function isSyndicated() { + return false; + } + + function isCacheable() { + return false; + } + + function linkParameters() { + return [ 'mime' => "{$this->major}/{$this->minor}" ]; + } + + public function getQueryInfo() { + $minorType = []; + if ( $this->minor !== '*' ) { + // Allow wildcard searching + $minorType['img_minor_mime'] = $this->minor; + } + $imgQuery = LocalFile::getQueryInfo(); + $qi = [ + 'tables' => $imgQuery['tables'], + 'fields' => [ + 'namespace' => NS_FILE, + 'title' => 'img_name', + // Still have a value field just in case, + // but it isn't actually used for sorting. + 'value' => 'img_name', + 'img_size', + 'img_width', + 'img_height', + 'img_user_text' => $imgQuery['fields']['img_user_text'], + 'img_timestamp' + ], + 'conds' => [ + 'img_major_mime' => $this->major, + // This is in order to trigger using + // the img_media_mime index in "range" mode. + // @todo how is order defined? use MimeAnalyzer::getMediaTypes? + 'img_media_type' => [ + MEDIATYPE_BITMAP, + MEDIATYPE_DRAWING, + MEDIATYPE_AUDIO, + MEDIATYPE_VIDEO, + MEDIATYPE_MULTIMEDIA, + MEDIATYPE_UNKNOWN, + MEDIATYPE_OFFICE, + MEDIATYPE_TEXT, + MEDIATYPE_EXECUTABLE, + MEDIATYPE_ARCHIVE, + MEDIATYPE_3D, + ], + ] + $minorType, + 'join_conds' => $imgQuery['joins'], + ]; + + return $qi; + } + + /** + * The index is on (img_media_type, img_major_mime, img_minor_mime) + * which unfortunately doesn't have img_name at the end for sorting. + * So tell db to sort it however it wishes (Its not super important + * that this report gives results in a logical order). As an aditional + * note, mysql seems to by default order things by img_name ASC, which + * is what we ideally want, so everything works out fine anyhow. + * @return array + */ + function getOrderFields() { + return []; + } + + /** + * Generate and output the form + */ + function getPageHeader() { + $formDescriptor = [ + 'mime' => [ + 'type' => 'combobox', + 'options' => $this->getSuggestionsForTypes(), + 'name' => 'mime', + 'label-message' => 'mimetype', + 'required' => true, + 'default' => $this->mime, + ], + ]; + + HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) + ->setSubmitTextMsg( 'ilsubmit' ) + ->setAction( $this->getPageTitle()->getLocalURL() ) + ->setMethod( 'get' ) + ->prepareForm() + ->displayForm( false ); + } + + protected function getSuggestionsForTypes() { + $dbr = wfGetDB( DB_REPLICA ); + $lastMajor = null; + $suggestions = []; + $result = $dbr->select( + [ 'image' ], + // We ignore img_media_type, but using it in the query is needed for MySQL to choose a + // sensible execution plan + [ 'img_media_type', 'img_major_mime', 'img_minor_mime' ], + [], + __METHOD__, + [ 'GROUP BY' => [ 'img_media_type', 'img_major_mime', 'img_minor_mime' ] ] + ); + foreach ( $result as $row ) { + $major = $row->img_major_mime; + $minor = $row->img_minor_mime; + $suggestions[ "$major/$minor" ] = "$major/$minor"; + if ( $lastMajor === $major ) { + // If there are at least two with the same major mime type, also include the wildcard + $suggestions[ "$major/*" ] = "$major/*"; + } + $lastMajor = $major; + } + ksort( $suggestions ); + return $suggestions; + } + + public function execute( $par ) { + $this->mime = $par ? $par : $this->getRequest()->getText( 'mime' ); + $this->mime = trim( $this->mime ); + list( $this->major, $this->minor ) = File::splitMime( $this->mime ); + + if ( $this->major == '' || $this->minor == '' || $this->minor == 'unknown' || + !self::isValidType( $this->major ) + ) { + $this->setHeaders(); + $this->outputHeader(); + $this->getPageHeader(); + return; + } + + parent::execute( $par ); + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + global $wgContLang; + + $linkRenderer = $this->getLinkRenderer(); + $nt = Title::makeTitle( $result->namespace, $result->title ); + $text = $wgContLang->convert( $nt->getText() ); + $plink = $linkRenderer->makeLink( + Title::newFromText( $nt->getPrefixedText() ), + $text + ); + + $download = Linker::makeMediaLinkObj( $nt, $this->msg( 'download' )->escaped() ); + $download = $this->msg( 'parentheses' )->rawParams( $download )->escaped(); + $lang = $this->getLanguage(); + $bytes = htmlspecialchars( $lang->formatSize( $result->img_size ) ); + $dimensions = $this->msg( 'widthheight' )->numParams( $result->img_width, + $result->img_height )->escaped(); + $user = $linkRenderer->makeLink( + Title::makeTitle( NS_USER, $result->img_user_text ), + $result->img_user_text + ); + + $time = $lang->userTimeAndDate( $result->img_timestamp, $this->getUser() ); + $time = htmlspecialchars( $time ); + + return "$download $plink . . $dimensions . . $bytes . . $user . . $time"; + } + + /** + * @param string $type + * @return bool + */ + protected static function isValidType( $type ) { + // From maintenance/tables.sql => img_major_mime + $types = [ + 'unknown', + 'application', + 'audio', + 'image', + 'text', + 'video', + 'message', + 'model', + 'multipart', + 'chemical' + ]; + + return in_array( $type, $types ); + } + + public function preprocessResults( $db, $res ) { + $this->executeLBFromResultWrapper( $res ); + } + + protected function getGroupName() { + return 'media'; + } +} diff --git a/www/wiki/includes/specials/SpecialMediaStatistics.php b/www/wiki/includes/specials/SpecialMediaStatistics.php new file mode 100644 index 00000000..943fa570 --- /dev/null +++ b/www/wiki/includes/specials/SpecialMediaStatistics.php @@ -0,0 +1,371 @@ +<?php +/** + * Implements Special:MediaStatistics + * + * 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 + * @author Brian Wolff + */ + +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * @ingroup SpecialPage + */ +class MediaStatisticsPage extends QueryPage { + protected $totalCount = 0, $totalBytes = 0; + + /** + * @var int $totalPerType Combined file size of all files in a section + */ + protected $totalPerType = 0; + + /** + * @var int $totalSize Combined file size of all files + */ + protected $totalSize = 0; + + function __construct( $name = 'MediaStatistics' ) { + parent::__construct( $name ); + // Generally speaking there is only a small number of file types, + // so just show all of them. + $this->limit = 5000; + $this->shownavigation = false; + } + + public function isExpensive() { + return true; + } + + /** + * Query to do. + * + * This abuses the query cache table by storing mime types as "titles". + * + * This will store entries like [[Media:BITMAP;image/jpeg;200;20000]] + * where the form is Media type;mime type;count;bytes. + * + * This relies on the behaviour that when value is tied, the order things + * come out of querycache table is the order they went in. Which is hacky. + * However, other special pages like Special:Deadendpages and + * Special:BrokenRedirects also rely on this. + * @return array + */ + public function getQueryInfo() { + $dbr = wfGetDB( DB_REPLICA ); + $fakeTitle = $dbr->buildConcat( [ + 'img_media_type', + $dbr->addQuotes( ';' ), + 'img_major_mime', + $dbr->addQuotes( '/' ), + 'img_minor_mime', + $dbr->addQuotes( ';' ), + 'COUNT(*)', + $dbr->addQuotes( ';' ), + 'SUM( img_size )' + ] ); + return [ + 'tables' => [ 'image' ], + 'fields' => [ + 'title' => $fakeTitle, + 'namespace' => NS_MEDIA, /* needs to be something */ + 'value' => '1' + ], + 'options' => [ + 'GROUP BY' => [ + 'img_media_type', + 'img_major_mime', + 'img_minor_mime', + ] + ] + ]; + } + + /** + * How to sort the results + * + * It's important that img_media_type come first, otherwise the + * tables will be fragmented. + * @return Array Fields to sort by + */ + function getOrderFields() { + return [ 'img_media_type', 'count(*)', 'img_major_mime', 'img_minor_mime' ]; + } + + /** + * Output the results of the query. + * + * @param OutputPage $out + * @param Skin $skin (deprecated presumably) + * @param IDatabase $dbr + * @param IResultWrapper $res Results from query + * @param int $num Number of results + * @param int $offset Paging offset (Should always be 0 in our case) + */ + protected function outputResults( $out, $skin, $dbr, $res, $num, $offset ) { + $prevMediaType = null; + foreach ( $res as $row ) { + $mediaStats = $this->splitFakeTitle( $row->title ); + if ( count( $mediaStats ) < 4 ) { + continue; + } + list( $mediaType, $mime, $totalCount, $totalBytes ) = $mediaStats; + if ( $prevMediaType !== $mediaType ) { + if ( $prevMediaType !== null ) { + // We're not at beginning, so we have to + // close the previous table. + $this->outputTableEnd(); + } + $this->outputMediaType( $mediaType ); + $this->totalPerType = 0; + $this->outputTableStart( $mediaType ); + $prevMediaType = $mediaType; + } + $this->outputTableRow( $mime, intval( $totalCount ), intval( $totalBytes ) ); + } + if ( $prevMediaType !== null ) { + $this->outputTableEnd(); + // add total size of all files + $this->outputMediaType( 'total' ); + $this->getOutput()->addWikiText( + $this->msg( 'mediastatistics-allbytes' ) + ->numParams( $this->totalSize ) + ->sizeParams( $this->totalSize ) + ->text() + ); + } + } + + /** + * Output closing </table> + */ + protected function outputTableEnd() { + $this->getOutput()->addHTML( Html::closeElement( 'table' ) ); + $this->getOutput()->addWikiText( + $this->msg( 'mediastatistics-bytespertype' ) + ->numParams( $this->totalPerType ) + ->sizeParams( $this->totalPerType ) + ->numParams( $this->makePercentPretty( $this->totalPerType / $this->totalBytes ) ) + ->text() + ); + $this->totalSize += $this->totalPerType; + } + + /** + * Output a row of the stats table + * + * @param string $mime mime type (e.g. image/jpeg) + * @param int $count Number of images of this type + * @param int $bytes Total space for images of this type + */ + protected function outputTableRow( $mime, $count, $bytes ) { + $mimeSearch = SpecialPage::getTitleFor( 'MIMEsearch', $mime ); + $linkRenderer = $this->getLinkRenderer(); + $row = Html::rawElement( + 'td', + [], + $linkRenderer->makeLink( $mimeSearch, $mime ) + ); + $row .= Html::element( + 'td', + [], + $this->getExtensionList( $mime ) + ); + $row .= Html::rawElement( + 'td', + // Make sure js sorts it in numeric order + [ 'data-sort-value' => $count ], + $this->msg( 'mediastatistics-nfiles' ) + ->numParams( $count ) + /** @todo Check to be sure this really should have number formatting */ + ->numParams( $this->makePercentPretty( $count / $this->totalCount ) ) + ->parse() + ); + $row .= Html::rawElement( + 'td', + // Make sure js sorts it in numeric order + [ 'data-sort-value' => $bytes ], + $this->msg( 'mediastatistics-nbytes' ) + ->numParams( $bytes ) + ->sizeParams( $bytes ) + /** @todo Check to be sure this really should have number formatting */ + ->numParams( $this->makePercentPretty( $bytes / $this->totalBytes ) ) + ->parse() + ); + $this->totalPerType += $bytes; + $this->getOutput()->addHTML( Html::rawElement( 'tr', [], $row ) ); + } + + /** + * @param float $decimal A decimal percentage (ie for 12.3%, this would be 0.123) + * @return String The percentage formatted so that 3 significant digits are shown. + */ + protected function makePercentPretty( $decimal ) { + $decimal *= 100; + // Always show three useful digits + if ( $decimal == 0 ) { + return '0'; + } + if ( $decimal >= 100 ) { + return '100'; + } + $percent = sprintf( "%." . max( 0, 2 - floor( log10( $decimal ) ) ) . "f", $decimal ); + // Then remove any trailing 0's + return preg_replace( '/\.?0*$/', '', $percent ); + } + + /** + * Given a mime type, return a comma separated list of allowed extensions. + * + * @param string $mime mime type + * @return string Comma separated list of allowed extensions (e.g. ".ogg, .oga") + */ + private function getExtensionList( $mime ) { + $exts = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer() + ->getExtensionsForType( $mime ); + if ( $exts === null ) { + return ''; + } + $extArray = explode( ' ', $exts ); + $extArray = array_unique( $extArray ); + foreach ( $extArray as &$ext ) { + $ext = '.' . $ext; + } + + return $this->getLanguage()->commaList( $extArray ); + } + + /** + * Output the start of the table + * + * Including opening <table>, and first <tr> with column headers. + * @param string $mediaType + */ + protected function outputTableStart( $mediaType ) { + $this->getOutput()->addHTML( + Html::openElement( + 'table', + [ 'class' => [ + 'mw-mediastats-table', + 'mw-mediastats-table-' . strtolower( $mediaType ), + 'sortable', + 'wikitable' + ] ] + ) + ); + $this->getOutput()->addHTML( $this->getTableHeaderRow() ); + } + + /** + * Get (not output) the header row for the table + * + * @return String the header row of the able + */ + protected function getTableHeaderRow() { + $headers = [ 'mimetype', 'extensions', 'count', 'totalbytes' ]; + $ths = ''; + foreach ( $headers as $header ) { + $ths .= Html::rawElement( + 'th', + [], + // for grep: + // mediastatistics-table-mimetype, mediastatistics-table-extensions + // tatistics-table-count, mediastatistics-table-totalbytes + $this->msg( 'mediastatistics-table-' . $header )->parse() + ); + } + return Html::rawElement( 'tr', [], $ths ); + } + + /** + * Output a header for a new media type section + * + * @param string $mediaType A media type (e.g. from the MEDIATYPE_xxx constants) + */ + protected function outputMediaType( $mediaType ) { + $this->getOutput()->addHTML( + Html::element( + 'h2', + [ 'class' => [ + 'mw-mediastats-mediatype', + 'mw-mediastats-mediatype-' . strtolower( $mediaType ) + ] ], + // for grep + // mediastatistics-header-unknown, mediastatistics-header-bitmap, + // mediastatistics-header-drawing, mediastatistics-header-audio, + // mediastatistics-header-video, mediastatistics-header-multimedia, + // mediastatistics-header-office, mediastatistics-header-text, + // mediastatistics-header-executable, mediastatistics-header-archive, + // mediastatistics-header-3d, + $this->msg( 'mediastatistics-header-' . strtolower( $mediaType ) )->text() + ) + ); + /** @todo Possibly could add a message here explaining what the different types are. + * not sure if it is needed though. + */ + } + + /** + * parse the fake title format that this special page abuses querycache with. + * + * @param string $fakeTitle A string formatted as <media type>;<mime type>;<count>;<bytes> + * @return array The constituant parts of $fakeTitle + */ + private function splitFakeTitle( $fakeTitle ) { + return explode( ';', $fakeTitle, 4 ); + } + + /** + * What group to put the page in + * @return string + */ + protected function getGroupName() { + return 'media'; + } + + /** + * This method isn't used, since we override outputResults, but + * we need to implement since abstract in parent class. + * + * @param Skin $skin + * @param stdClass $result Result row + * @return bool|string|void + * @throws MWException + */ + public function formatResult( $skin, $result ) { + throw new MWException( "unimplemented" ); + } + + /** + * Initialize total values so we can figure out percentages later. + * + * @param IDatabase $dbr + * @param IResultWrapper $res + */ + public function preprocessResults( $dbr, $res ) { + $this->executeLBFromResultWrapper( $res ); + $this->totalCount = $this->totalBytes = 0; + foreach ( $res as $row ) { + $mediaStats = $this->splitFakeTitle( $row->title ); + $this->totalCount += isset( $mediaStats[2] ) ? $mediaStats[2] : 0; + $this->totalBytes += isset( $mediaStats[3] ) ? $mediaStats[3] : 0; + } + $res->seek( 0 ); + } +} diff --git a/www/wiki/includes/specials/SpecialMergeHistory.php b/www/wiki/includes/specials/SpecialMergeHistory.php new file mode 100644 index 00000000..f122db8a --- /dev/null +++ b/www/wiki/includes/specials/SpecialMergeHistory.php @@ -0,0 +1,385 @@ +<?php +/** + * Implements Special:MergeHistory + * + * 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 + */ + +/** + * Special page allowing users with the appropriate permissions to + * merge article histories, with some restrictions + * + * @ingroup SpecialPage + */ +class SpecialMergeHistory extends SpecialPage { + /** @var string */ + protected $mAction; + + /** @var string */ + protected $mTarget; + + /** @var string */ + protected $mDest; + + /** @var string */ + protected $mTimestamp; + + /** @var int */ + protected $mTargetID; + + /** @var int */ + protected $mDestID; + + /** @var string */ + protected $mComment; + + /** @var bool Was posted? */ + protected $mMerge; + + /** @var bool Was submitted? */ + protected $mSubmitted; + + /** @var Title */ + protected $mTargetObj; + + /** @var Title */ + protected $mDestObj; + + /** @var int[] */ + public $prevId; + + public function __construct() { + parent::__construct( 'MergeHistory', 'mergehistory' ); + } + + public function doesWrites() { + return true; + } + + /** + * @return void + */ + private function loadRequestParams() { + $request = $this->getRequest(); + $this->mAction = $request->getVal( 'action' ); + $this->mTarget = $request->getVal( 'target' ); + $this->mDest = $request->getVal( 'dest' ); + $this->mSubmitted = $request->getBool( 'submitted' ); + + $this->mTargetID = intval( $request->getVal( 'targetID' ) ); + $this->mDestID = intval( $request->getVal( 'destID' ) ); + $this->mTimestamp = $request->getVal( 'mergepoint' ); + if ( !preg_match( '/[0-9]{14}/', $this->mTimestamp ) ) { + $this->mTimestamp = ''; + } + $this->mComment = $request->getText( 'wpComment' ); + + $this->mMerge = $request->wasPosted() + && $this->getUser()->matchEditToken( $request->getVal( 'wpEditToken' ) ); + + // target page + if ( $this->mSubmitted ) { + $this->mTargetObj = Title::newFromText( $this->mTarget ); + $this->mDestObj = Title::newFromText( $this->mDest ); + } else { + $this->mTargetObj = null; + $this->mDestObj = null; + } + } + + public function execute( $par ) { + $this->useTransactionalTimeLimit(); + + $this->checkPermissions(); + $this->checkReadOnly(); + + $this->loadRequestParams(); + + $this->setHeaders(); + $this->outputHeader(); + + if ( $this->mTargetID && $this->mDestID && $this->mAction == 'submit' && $this->mMerge ) { + $this->merge(); + + return; + } + + if ( !$this->mSubmitted ) { + $this->showMergeForm(); + + return; + } + + $errors = []; + if ( !$this->mTargetObj instanceof Title ) { + $errors[] = $this->msg( 'mergehistory-invalid-source' )->parseAsBlock(); + } elseif ( !$this->mTargetObj->exists() ) { + $errors[] = $this->msg( 'mergehistory-no-source', + wfEscapeWikiText( $this->mTargetObj->getPrefixedText() ) + )->parseAsBlock(); + } + + if ( !$this->mDestObj instanceof Title ) { + $errors[] = $this->msg( 'mergehistory-invalid-destination' )->parseAsBlock(); + } elseif ( !$this->mDestObj->exists() ) { + $errors[] = $this->msg( 'mergehistory-no-destination', + wfEscapeWikiText( $this->mDestObj->getPrefixedText() ) + )->parseAsBlock(); + } + + if ( $this->mTargetObj && $this->mDestObj && $this->mTargetObj->equals( $this->mDestObj ) ) { + $errors[] = $this->msg( 'mergehistory-same-destination' )->parseAsBlock(); + } + + if ( count( $errors ) ) { + $this->showMergeForm(); + $this->getOutput()->addHTML( implode( "\n", $errors ) ); + } else { + $this->showHistory(); + } + } + + function showMergeForm() { + $out = $this->getOutput(); + $out->addWikiMsg( 'mergehistory-header' ); + + $out->addHTML( + Xml::openElement( 'form', [ + 'method' => 'get', + 'action' => wfScript() ] ) . + '<fieldset>' . + Xml::element( 'legend', [], + $this->msg( 'mergehistory-box' )->text() ) . + Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) . + Html::hidden( 'submitted', '1' ) . + Html::hidden( 'mergepoint', $this->mTimestamp ) . + Xml::openElement( 'table' ) . + '<tr> + <td>' . Xml::label( $this->msg( 'mergehistory-from' )->text(), 'target' ) . '</td> + <td>' . Xml::input( 'target', 30, $this->mTarget, [ 'id' => 'target' ] ) . '</td> + </tr><tr> + <td>' . Xml::label( $this->msg( 'mergehistory-into' )->text(), 'dest' ) . '</td> + <td>' . Xml::input( 'dest', 30, $this->mDest, [ 'id' => 'dest' ] ) . '</td> + </tr><tr><td>' . + Xml::submitButton( $this->msg( 'mergehistory-go' )->text() ) . + '</td></tr>' . + Xml::closeElement( 'table' ) . + '</fieldset>' . + '</form>' + ); + + $this->addHelpLink( 'Help:Merge history' ); + } + + private function showHistory() { + $this->showMergeForm(); + + # List all stored revisions + $revisions = new MergeHistoryPager( + $this, [], $this->mTargetObj, $this->mDestObj + ); + $haveRevisions = $revisions && $revisions->getNumRows() > 0; + + $out = $this->getOutput(); + $titleObj = $this->getPageTitle(); + $action = $titleObj->getLocalURL( [ 'action' => 'submit' ] ); + # Start the form here + $top = Xml::openElement( + 'form', + [ + 'method' => 'post', + 'action' => $action, + 'id' => 'merge' + ] + ); + $out->addHTML( $top ); + + if ( $haveRevisions ) { + # Format the user-visible controls (comment field, submission button) + # in a nice little table + $table = + Xml::openElement( 'fieldset' ) . + $this->msg( 'mergehistory-merge', $this->mTargetObj->getPrefixedText(), + $this->mDestObj->getPrefixedText() )->parse() . + Xml::openElement( 'table', [ 'id' => 'mw-mergehistory-table' ] ) . + '<tr> + <td class="mw-label">' . + Xml::label( $this->msg( 'mergehistory-reason' )->text(), 'wpComment' ) . + '</td> + <td class="mw-input">' . + Xml::input( 'wpComment', 50, $this->mComment, [ 'id' => 'wpComment' ] ) . + '</td> + </tr> + <tr> + <td> </td> + <td class="mw-submit">' . + Xml::submitButton( + $this->msg( 'mergehistory-submit' )->text(), + [ 'name' => 'merge', 'id' => 'mw-merge-submit' ] + ) . + '</td> + </tr>' . + Xml::closeElement( 'table' ) . + Xml::closeElement( 'fieldset' ); + + $out->addHTML( $table ); + } + + $out->addHTML( + '<h2 id="mw-mergehistory">' . + $this->msg( 'mergehistory-list' )->escaped() . "</h2>\n" + ); + + if ( $haveRevisions ) { + $out->addHTML( $revisions->getNavigationBar() ); + $out->addHTML( '<ul>' ); + $out->addHTML( $revisions->getBody() ); + $out->addHTML( '</ul>' ); + $out->addHTML( $revisions->getNavigationBar() ); + } else { + $out->addWikiMsg( 'mergehistory-empty' ); + } + + # Show relevant lines from the merge log: + $mergeLogPage = new LogPage( 'merge' ); + $out->addHTML( '<h2>' . $mergeLogPage->getName()->escaped() . "</h2>\n" ); + LogEventsList::showLogExtract( $out, 'merge', $this->mTargetObj ); + + # When we submit, go by page ID to avoid some nasty but unlikely collisions. + # Such would happen if a page was renamed after the form loaded, but before submit + $misc = Html::hidden( 'targetID', $this->mTargetObj->getArticleID() ); + $misc .= Html::hidden( 'destID', $this->mDestObj->getArticleID() ); + $misc .= Html::hidden( 'target', $this->mTarget ); + $misc .= Html::hidden( 'dest', $this->mDest ); + $misc .= Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ); + $misc .= Xml::closeElement( 'form' ); + $out->addHTML( $misc ); + + return true; + } + + function formatRevisionRow( $row ) { + $rev = new Revision( $row ); + + $linkRenderer = $this->getLinkRenderer(); + + $stxt = ''; + $last = $this->msg( 'last' )->escaped(); + + $ts = wfTimestamp( TS_MW, $row->rev_timestamp ); + $checkBox = Xml::radio( 'mergepoint', $ts, ( $this->mTimestamp === $ts ) ); + + $user = $this->getUser(); + + $pageLink = $linkRenderer->makeKnownLink( + $rev->getTitle(), + $this->getLanguage()->userTimeAndDate( $ts, $user ), + [], + [ 'oldid' => $rev->getId() ] + ); + if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) { + $pageLink = '<span class="history-deleted">' . $pageLink . '</span>'; + } + + # Last link + if ( !$rev->userCan( Revision::DELETED_TEXT, $user ) ) { + $last = $this->msg( 'last' )->escaped(); + } elseif ( isset( $this->prevId[$row->rev_id] ) ) { + $last = $linkRenderer->makeKnownLink( + $rev->getTitle(), + $this->msg( 'last' )->text(), + [], + [ + 'diff' => $row->rev_id, + 'oldid' => $this->prevId[$row->rev_id] + ] + ); + } + + $userLink = Linker::revUserTools( $rev ); + + $size = $row->rev_len; + if ( !is_null( $size ) ) { + $stxt = Linker::formatRevisionSize( $size ); + } + $comment = Linker::revComment( $rev ); + + return Html::rawElement( 'li', [], + $this->msg( 'mergehistory-revisionrow' ) + ->rawParams( $checkBox, $last, $pageLink, $userLink, $stxt, $comment )->escaped() ); + } + + /** + * Actually attempt the history move + * + * @todo if all versions of page A are moved to B and then a user + * tries to do a reverse-merge via the "unmerge" log link, then page + * A will still be a redirect (as it was after the original merge), + * though it will have the old revisions back from before (as expected). + * The user may have to "undo" the redirect manually to finish the "unmerge". + * Maybe this should delete redirects at the target page of merges? + * + * @return bool Success + */ + function merge() { + # Get the titles directly from the IDs, in case the target page params + # were spoofed. The queries are done based on the IDs, so it's best to + # keep it consistent... + $targetTitle = Title::newFromID( $this->mTargetID ); + $destTitle = Title::newFromID( $this->mDestID ); + if ( is_null( $targetTitle ) || is_null( $destTitle ) ) { + return false; // validate these + } + if ( $targetTitle->getArticleID() == $destTitle->getArticleID() ) { + return false; + } + + // MergeHistory object + $mh = new MergeHistory( $targetTitle, $destTitle, $this->mTimestamp ); + + // Merge! + $mergeStatus = $mh->merge( $this->getUser(), $this->mComment ); + if ( !$mergeStatus->isOK() ) { + // Failed merge + $this->getOutput()->addWikiMsg( $mergeStatus->getMessage() ); + return false; + } + + $linkRenderer = $this->getLinkRenderer(); + + $targetLink = $linkRenderer->makeLink( + $targetTitle, + null, + [], + [ 'redirect' => 'no' ] + ); + + $this->getOutput()->addWikiMsg( $this->msg( 'mergehistory-done' ) + ->rawParams( $targetLink ) + ->params( $destTitle->getPrefixedText() ) + ->numParams( $mh->getMergedRevisionCount() ) + ); + + return true; + } + + protected function getGroupName() { + return 'pagetools'; + } +} diff --git a/www/wiki/includes/specials/SpecialMostcategories.php b/www/wiki/includes/specials/SpecialMostcategories.php new file mode 100644 index 00000000..123c1740 --- /dev/null +++ b/www/wiki/includes/specials/SpecialMostcategories.php @@ -0,0 +1,112 @@ +<?php +/** + * Implements Special:Mostcategories + * + * Copyright © 2005 Ævar Arnfjörð Bjarmason + * + * 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 + * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com> + */ + +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * A special page that list pages that have highest category count + * + * @ingroup SpecialPage + */ +class MostcategoriesPage extends QueryPage { + function __construct( $name = 'Mostcategories' ) { + parent::__construct( $name ); + } + + public function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + public function getQueryInfo() { + return [ + 'tables' => [ 'categorylinks', 'page' ], + 'fields' => [ + 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'COUNT(*)' + ], + 'conds' => [ 'page_namespace' => MWNamespace::getContentNamespaces() ], + 'options' => [ + 'HAVING' => 'COUNT(*) > 1', + 'GROUP BY' => [ 'page_namespace', 'page_title' ] + ], + 'join_conds' => [ + 'page' => [ + 'LEFT JOIN', + 'page_id = cl_from' + ] + ] + ]; + } + + /** + * @param IDatabase $db + * @param IResultWrapper $res + */ + function preprocessResults( $db, $res ) { + $this->executeLBFromResultWrapper( $res ); + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + $title = Title::makeTitleSafe( $result->namespace, $result->title ); + if ( !$title ) { + return Html::element( + 'span', + [ 'class' => 'mw-invalidtitle' ], + Linker::getInvalidTitleDescription( + $this->getContext(), + $result->namespace, + $result->title + ) + ); + } + + $linkRenderer = $this->getLinkRenderer(); + if ( $this->isCached() ) { + $link = $linkRenderer->makeLink( $title ); + } else { + $link = $linkRenderer->makeKnownLink( $title ); + } + + $count = $this->msg( 'ncategories' )->numParams( $result->value )->escaped(); + + return $this->getLanguage()->specialList( $link, $count ); + } + + protected function getGroupName() { + return 'highuse'; + } +} diff --git a/www/wiki/includes/specials/SpecialMostimages.php b/www/wiki/includes/specials/SpecialMostimages.php new file mode 100644 index 00000000..1339f4bc --- /dev/null +++ b/www/wiki/includes/specials/SpecialMostimages.php @@ -0,0 +1,67 @@ +<?php +/** + * Implements Special:Mostimages + * + * Copyright © 2005 Ævar Arnfjörð Bjarmason + * + * 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 + * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com> + */ + +/** + * A special page that lists most used images + * + * @ingroup SpecialPage + */ +class MostimagesPage extends ImageQueryPage { + function __construct( $name = 'Mostimages' ) { + parent::__construct( $name ); + } + + function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + function getQueryInfo() { + return [ + 'tables' => [ 'imagelinks' ], + 'fields' => [ + 'namespace' => NS_FILE, + 'title' => 'il_to', + 'value' => 'COUNT(*)' + ], + 'options' => [ + 'GROUP BY' => 'il_to', + 'HAVING' => 'COUNT(*) > 1' + ] + ]; + } + + function getCellHtml( $row ) { + return $this->msg( 'nimagelinks' )->numParams( $row->value )->escaped() . '<br />'; + } + + protected function getGroupName() { + return 'highuse'; + } +} diff --git a/www/wiki/includes/specials/SpecialMostinterwikis.php b/www/wiki/includes/specials/SpecialMostinterwikis.php new file mode 100644 index 00000000..c9638384 --- /dev/null +++ b/www/wiki/includes/specials/SpecialMostinterwikis.php @@ -0,0 +1,115 @@ +<?php +/** + * Implements Special:Mostinterwikis + * + * 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 + */ + +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * A special page that listed pages that have highest interwiki count + * + * @ingroup SpecialPage + */ +class MostinterwikisPage extends QueryPage { + function __construct( $name = 'Mostinterwikis' ) { + parent::__construct( $name ); + } + + public function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + public function getQueryInfo() { + return [ + 'tables' => [ + 'langlinks', + 'page' + ], 'fields' => [ + 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'COUNT(*)' + ], 'conds' => [ + 'page_namespace' => MWNamespace::getContentNamespaces() + ], 'options' => [ + 'HAVING' => 'COUNT(*) > 1', + 'GROUP BY' => [ + 'page_namespace', + 'page_title' + ] + ], 'join_conds' => [ + 'page' => [ + 'LEFT JOIN', + 'page_id = ll_from' + ] + ] + ]; + } + + /** + * Pre-fill the link cache + * + * @param IDatabase $db + * @param IResultWrapper $res + */ + function preprocessResults( $db, $res ) { + $this->executeLBFromResultWrapper( $res ); + } + + /** + * @param Skin $skin + * @param object $result + * @return string + */ + function formatResult( $skin, $result ) { + $title = Title::makeTitleSafe( $result->namespace, $result->title ); + if ( !$title ) { + return Html::element( + 'span', + [ 'class' => 'mw-invalidtitle' ], + Linker::getInvalidTitleDescription( + $this->getContext(), + $result->namespace, + $result->title + ) + ); + } + + $linkRenderer = $this->getLinkRenderer(); + if ( $this->isCached() ) { + $link = $linkRenderer->makeLink( $title ); + } else { + $link = $linkRenderer->makeKnownLink( $title ); + } + + $count = $this->msg( 'ninterwikis' )->numParams( $result->value )->escaped(); + + return $this->getLanguage()->specialList( $link, $count ); + } + + protected function getGroupName() { + return 'highuse'; + } +} diff --git a/www/wiki/includes/specials/SpecialMostlinked.php b/www/wiki/includes/specials/SpecialMostlinked.php new file mode 100644 index 00000000..c4553a4f --- /dev/null +++ b/www/wiki/includes/specials/SpecialMostlinked.php @@ -0,0 +1,135 @@ +<?php +/** + * Implements Special:Mostlinked + * + * Copyright © 2005 Ævar Arnfjörð Bjarmason, 2006 Rob Church + * + * 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 + * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com> + * @author Rob Church <robchur@gmail.com> + */ + +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * A special page to show pages ordered by the number of pages linking to them. + * + * @ingroup SpecialPage + */ +class MostlinkedPage extends QueryPage { + function __construct( $name = 'Mostlinked' ) { + parent::__construct( $name ); + } + + public function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + public function getQueryInfo() { + return [ + 'tables' => [ 'pagelinks', 'page' ], + 'fields' => [ + 'namespace' => 'pl_namespace', + 'title' => 'pl_title', + 'value' => 'COUNT(*)', + 'page_namespace' + ], + 'options' => [ + 'HAVING' => 'COUNT(*) > 1', + 'GROUP BY' => [ + 'pl_namespace', 'pl_title', + 'page_namespace' + ] + ], + 'join_conds' => [ + 'page' => [ + 'LEFT JOIN', + [ + 'page_namespace = pl_namespace', + 'page_title = pl_title' + ] + ] + ] + ]; + } + + /** + * Pre-fill the link cache + * + * @param IDatabase $db + * @param IResultWrapper $res + */ + function preprocessResults( $db, $res ) { + $this->executeLBFromResultWrapper( $res ); + } + + /** + * Make a link to "what links here" for the specified title + * + * @param Title $title Title being queried + * @param string $caption Text to display on the link + * @return string + */ + function makeWlhLink( $title, $caption ) { + $wlh = SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedDBkey() ); + + $linkRenderer = $this->getLinkRenderer(); + return $linkRenderer->makeKnownLink( $wlh, $caption ); + } + + /** + * Make links to the page corresponding to the item, + * and the "what links here" page for it + * + * @param Skin $skin Skin to be used + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + $title = Title::makeTitleSafe( $result->namespace, $result->title ); + if ( !$title ) { + return Html::element( + 'span', + [ 'class' => 'mw-invalidtitle' ], + Linker::getInvalidTitleDescription( + $this->getContext(), + $result->namespace, + $result->title ) + ); + } + + $linkRenderer = $this->getLinkRenderer(); + $link = $linkRenderer->makeLink( $title ); + $wlh = $this->makeWlhLink( + $title, + $this->msg( 'nlinks' )->numParams( $result->value )->text() + ); + + return $this->getLanguage()->specialList( $link, $wlh ); + } + + protected function getGroupName() { + return 'highuse'; + } +} diff --git a/www/wiki/includes/specials/SpecialMostlinkedcategories.php b/www/wiki/includes/specials/SpecialMostlinkedcategories.php new file mode 100644 index 00000000..f238f6c0 --- /dev/null +++ b/www/wiki/includes/specials/SpecialMostlinkedcategories.php @@ -0,0 +1,98 @@ +<?php +/** + * Implements Special:Mostlinkedcategories + * + * Copyright © 2005, Ævar Arnfjörð Bjarmason + * + * 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 + * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com> + */ + +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * A querypage to show categories ordered in descending order by the pages in them + * + * @ingroup SpecialPage + */ +class MostlinkedCategoriesPage extends QueryPage { + function __construct( $name = 'Mostlinkedcategories' ) { + parent::__construct( $name ); + } + + function isSyndicated() { + return false; + } + + public function getQueryInfo() { + return [ + 'tables' => [ 'category' ], + 'fields' => [ 'title' => 'cat_title', + 'namespace' => NS_CATEGORY, + 'value' => 'cat_pages' ], + 'conds' => [ 'cat_pages > 0' ], + ]; + } + + function sortDescending() { + return true; + } + + /** + * Fetch user page links and cache their existence + * + * @param IDatabase $db + * @param IResultWrapper $res + */ + function preprocessResults( $db, $res ) { + $this->executeLBFromResultWrapper( $res ); + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + global $wgContLang; + + $nt = Title::makeTitleSafe( NS_CATEGORY, $result->title ); + if ( !$nt ) { + return Html::element( + 'span', + [ 'class' => 'mw-invalidtitle' ], + Linker::getInvalidTitleDescription( + $this->getContext(), + NS_CATEGORY, + $result->title ) + ); + } + + $text = $wgContLang->convert( $nt->getText() ); + $plink = $this->getLinkRenderer()->makeLink( $nt, $text ); + $nlinks = $this->msg( 'nmembers' )->numParams( $result->value )->escaped(); + + return $this->getLanguage()->specialList( $plink, $nlinks ); + } + + protected function getGroupName() { + return 'highuse'; + } +} diff --git a/www/wiki/includes/specials/SpecialMostlinkedtemplates.php b/www/wiki/includes/specials/SpecialMostlinkedtemplates.php new file mode 100644 index 00000000..4544468d --- /dev/null +++ b/www/wiki/includes/specials/SpecialMostlinkedtemplates.php @@ -0,0 +1,132 @@ +<?php +/** + * Implements Special:Mostlinkedtemplates + * + * 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 + * @author Rob Church <robchur@gmail.com> + */ + +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * Special page lists templates with a large number of + * transclusion links, i.e. "most used" templates + * + * @ingroup SpecialPage + */ +class MostlinkedTemplatesPage extends QueryPage { + function __construct( $name = 'Mostlinkedtemplates' ) { + parent::__construct( $name ); + } + + /** + * Is this report expensive, i.e should it be cached? + * + * @return bool + */ + public function isExpensive() { + return true; + } + + /** + * Is there a feed available? + * + * @return bool + */ + public function isSyndicated() { + return false; + } + + /** + * Sort the results in descending order? + * + * @return bool + */ + public function sortDescending() { + return true; + } + + public function getQueryInfo() { + return [ + 'tables' => [ 'templatelinks' ], + 'fields' => [ + 'namespace' => 'tl_namespace', + 'title' => 'tl_title', + 'value' => 'COUNT(*)' + ], + 'options' => [ 'GROUP BY' => [ 'tl_namespace', 'tl_title' ] ] + ]; + } + + /** + * Pre-cache page existence to speed up link generation + * + * @param IDatabase $db + * @param IResultWrapper $res + */ + public function preprocessResults( $db, $res ) { + $this->executeLBFromResultWrapper( $res ); + } + + /** + * Format a result row + * + * @param Skin $skin + * @param object $result Result row + * @return string + */ + public function formatResult( $skin, $result ) { + $title = Title::makeTitleSafe( $result->namespace, $result->title ); + if ( !$title ) { + return Html::element( + 'span', + [ 'class' => 'mw-invalidtitle' ], + Linker::getInvalidTitleDescription( + $this->getContext(), + $result->namespace, + $result->title + ) + ); + } + + return $this->getLanguage()->specialList( + $this->getLinkRenderer()->makeLink( $title ), + $this->makeWlhLink( $title, $result ) + ); + } + + /** + * Make a "what links here" link for a given title + * + * @param Title $title Title to make the link for + * @param object $result Result row + * @return string + */ + private function makeWlhLink( $title, $result ) { + $wlh = SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedText() ); + $label = $this->msg( 'ntransclusions' )->numParams( $result->value )->text(); + + return $this->getLinkRenderer()->makeLink( $wlh, $label ); + } + + protected function getGroupName() { + return 'highuse'; + } +} diff --git a/www/wiki/includes/specials/SpecialMostrevisions.php b/www/wiki/includes/specials/SpecialMostrevisions.php new file mode 100644 index 00000000..0471cafe --- /dev/null +++ b/www/wiki/includes/specials/SpecialMostrevisions.php @@ -0,0 +1,39 @@ +<?php +/** + * Implements Special:Mostrevisions + * + * Copyright © 2005 Ævar Arnfjörð Bjarmason + * + * 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 + * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com> + */ + +class MostrevisionsPage extends FewestrevisionsPage { + function __construct( $name = 'Mostrevisions' ) { + parent::__construct( $name ); + } + + function sortDescending() { + return true; + } + + protected function getGroupName() { + return 'highuse'; + } +} diff --git a/www/wiki/includes/specials/SpecialMovepage.php b/www/wiki/includes/specials/SpecialMovepage.php new file mode 100644 index 00000000..d30ff432 --- /dev/null +++ b/www/wiki/includes/specials/SpecialMovepage.php @@ -0,0 +1,872 @@ +<?php +/** + * Implements Special:Movepage + * + * 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 + */ + +/** + * A special page that allows users to change page titles + * + * @ingroup SpecialPage + */ +class MovePageForm extends UnlistedSpecialPage { + /** @var Title */ + protected $oldTitle = null; + + /** @var Title */ + protected $newTitle; + + /** @var string Text input */ + protected $reason; + + // Checks + + /** @var bool */ + protected $moveTalk; + + /** @var bool */ + protected $deleteAndMove; + + /** @var bool */ + protected $moveSubpages; + + /** @var bool */ + protected $fixRedirects; + + /** @var bool */ + protected $leaveRedirect; + + /** @var bool */ + protected $moveOverShared; + + private $watch = false; + + public function __construct() { + parent::__construct( 'Movepage' ); + } + + public function doesWrites() { + return true; + } + + public function execute( $par ) { + $this->useTransactionalTimeLimit(); + + $this->checkReadOnly(); + + $this->setHeaders(); + $this->outputHeader(); + + $request = $this->getRequest(); + $target = !is_null( $par ) ? $par : $request->getVal( 'target' ); + + // Yes, the use of getVal() and getText() is wanted, see T22365 + + $oldTitleText = $request->getVal( 'wpOldTitle', $target ); + $this->oldTitle = Title::newFromText( $oldTitleText ); + + if ( !$this->oldTitle ) { + // Either oldTitle wasn't passed, or newFromText returned null + throw new ErrorPageError( 'notargettitle', 'notargettext' ); + } + if ( !$this->oldTitle->exists() ) { + throw new ErrorPageError( 'nopagetitle', 'nopagetext' ); + } + + $newTitleTextMain = $request->getText( 'wpNewTitleMain' ); + $newTitleTextNs = $request->getInt( 'wpNewTitleNs', $this->oldTitle->getNamespace() ); + // Backwards compatibility for forms submitting here from other sources + // which is more common than it should be.. + $newTitleText_bc = $request->getText( 'wpNewTitle' ); + $this->newTitle = strlen( $newTitleText_bc ) > 0 + ? Title::newFromText( $newTitleText_bc ) + : Title::makeTitleSafe( $newTitleTextNs, $newTitleTextMain ); + + $user = $this->getUser(); + + # Check rights + $permErrors = $this->oldTitle->getUserPermissionsErrors( 'move', $user ); + if ( count( $permErrors ) ) { + // Auto-block user's IP if the account was "hard" blocked + DeferredUpdates::addCallableUpdate( function () use ( $user ) { + $user->spreadAnyEditBlock(); + } ); + throw new PermissionsError( 'move', $permErrors ); + } + + $def = !$request->wasPosted(); + + $this->reason = $request->getText( 'wpReason' ); + $this->moveTalk = $request->getBool( 'wpMovetalk', $def ); + $this->fixRedirects = $request->getBool( 'wpFixRedirects', $def ); + $this->leaveRedirect = $request->getBool( 'wpLeaveRedirect', $def ); + $this->moveSubpages = $request->getBool( 'wpMovesubpages' ); + $this->deleteAndMove = $request->getBool( 'wpDeleteAndMove' ); + $this->moveOverShared = $request->getBool( 'wpMoveOverSharedFile' ); + $this->watch = $request->getCheck( 'wpWatch' ) && $user->isLoggedIn(); + + if ( 'submit' == $request->getVal( 'action' ) && $request->wasPosted() + && $user->matchEditToken( $request->getVal( 'wpEditToken' ) ) + ) { + $this->doSubmit(); + } else { + $this->showForm( [] ); + } + } + + /** + * Show the form + * + * @param array $err Error messages. Each item is an error message. + * It may either be a string message name or array message name and + * parameters, like the second argument to OutputPage::wrapWikiMsg(). + */ + function showForm( $err ) { + $this->getSkin()->setRelevantTitle( $this->oldTitle ); + + $out = $this->getOutput(); + $out->setPageTitle( $this->msg( 'move-page', $this->oldTitle->getPrefixedText() ) ); + $out->addModules( 'mediawiki.special.movePage' ); + $out->addModuleStyles( 'mediawiki.special.movePage.styles' ); + $this->addHelpLink( 'Help:Moving a page' ); + + $out->addWikiMsg( $this->getConfig()->get( 'FixDoubleRedirects' ) ? + 'movepagetext' : + 'movepagetext-noredirectfixer' + ); + + if ( $this->oldTitle->getNamespace() == NS_USER && !$this->oldTitle->isSubpage() ) { + $out->wrapWikiMsg( + "<div class=\"warningbox mw-moveuserpage-warning\">\n$1\n</div>", + 'moveuserpage-warning' + ); + } elseif ( $this->oldTitle->getNamespace() == NS_CATEGORY ) { + $out->wrapWikiMsg( + "<div class=\"warningbox mw-movecategorypage-warning\">\n$1\n</div>", + 'movecategorypage-warning' + ); + } + + $deleteAndMove = false; + $moveOverShared = false; + + $newTitle = $this->newTitle; + + if ( !$newTitle ) { + # Show the current title as a default + # when the form is first opened. + $newTitle = $this->oldTitle; + } elseif ( !count( $err ) ) { + # If a title was supplied, probably from the move log revert + # link, check for validity. We can then show some diagnostic + # information and save a click. + $newerr = $this->oldTitle->isValidMoveOperation( $newTitle ); + if ( is_array( $newerr ) ) { + $err = $newerr; + } + } + + $user = $this->getUser(); + + if ( count( $err ) == 1 && isset( $err[0][0] ) && $err[0][0] == 'articleexists' + && $newTitle->quickUserCan( 'delete', $user ) + ) { + $out->wrapWikiMsg( + "<div class='warningbox'>\n$1\n</div>\n", + [ 'delete_and_move_text', $newTitle->getPrefixedText() ] + ); + $deleteAndMove = true; + $err = []; + } + + if ( count( $err ) == 1 && isset( $err[0][0] ) && $err[0][0] == 'file-exists-sharedrepo' + && $user->isAllowed( 'reupload-shared' ) + ) { + $out->wrapWikiMsg( + "<div class='warningbox'>\n$1\n</div>\n", + [ + 'move-over-sharedrepo', + $newTitle->getPrefixedText() + ] + ); + $moveOverShared = true; + $err = []; + } + + $oldTalk = $this->oldTitle->getTalkPage(); + $oldTitleSubpages = $this->oldTitle->hasSubpages(); + $oldTitleTalkSubpages = $this->oldTitle->getTalkPage()->hasSubpages(); + + $canMoveSubpage = ( $oldTitleSubpages || $oldTitleTalkSubpages ) && + !count( $this->oldTitle->getUserPermissionsErrors( 'move-subpages', $user ) ); + + # We also want to be able to move assoc. subpage talk-pages even if base page + # has no associated talk page, so || with $oldTitleTalkSubpages. + $considerTalk = !$this->oldTitle->isTalkPage() && + ( $oldTalk->exists() + || ( $oldTitleTalkSubpages && $canMoveSubpage ) ); + + $dbr = wfGetDB( DB_REPLICA ); + if ( $this->getConfig()->get( 'FixDoubleRedirects' ) ) { + $hasRedirects = $dbr->selectField( 'redirect', '1', + [ + 'rd_namespace' => $this->oldTitle->getNamespace(), + 'rd_title' => $this->oldTitle->getDBkey(), + ], __METHOD__ ); + } else { + $hasRedirects = false; + } + + if ( count( $err ) ) { + $action_desc = $this->msg( 'action-move' )->plain(); + $errMsgHtml = $this->msg( 'permissionserrorstext-withaction', + count( $err ), $action_desc )->parseAsBlock(); + + if ( count( $err ) == 1 ) { + $errMsg = $err[0]; + $errMsgName = array_shift( $errMsg ); + + if ( $errMsgName == 'hookaborted' ) { + $errMsgHtml .= "<p>{$errMsg[0]}</p>\n"; + } else { + $errMsgHtml .= $this->msg( $errMsgName, $errMsg )->parseAsBlock(); + } + } else { + $errStr = []; + + foreach ( $err as $errMsg ) { + if ( $errMsg[0] == 'hookaborted' ) { + $errStr[] = $errMsg[1]; + } else { + $errMsgName = array_shift( $errMsg ); + $errStr[] = $this->msg( $errMsgName, $errMsg )->parse(); + } + } + + $errMsgHtml .= '<ul><li>' . implode( "</li>\n<li>", $errStr ) . "</li></ul>\n"; + } + $out->addHTML( Html::errorBox( $errMsgHtml ) ); + } + + if ( $this->oldTitle->isProtected( 'move' ) ) { + # Is the title semi-protected? + if ( $this->oldTitle->isSemiProtected( 'move' ) ) { + $noticeMsg = 'semiprotectedpagemovewarning'; + $classes[] = 'mw-textarea-sprotected'; + } else { + # Then it must be protected based on static groups (regular) + $noticeMsg = 'protectedpagemovewarning'; + $classes[] = 'mw-textarea-protected'; + } + $out->addHTML( "<div class='mw-warning-with-logexcerpt'>\n" ); + $out->addWikiMsg( $noticeMsg ); + LogEventsList::showLogExtract( + $out, + 'protect', + $this->oldTitle, + '', + [ 'lim' => 1 ] + ); + $out->addHTML( "</div>\n" ); + } + + // Length limit for wpReason and wpNewTitleMain is enforced in the + // mediawiki.special.movePage module + + $immovableNamespaces = []; + foreach ( array_keys( $this->getLanguage()->getNamespaces() ) as $nsId ) { + if ( !MWNamespace::isMovable( $nsId ) ) { + $immovableNamespaces[] = $nsId; + } + } + + $handler = ContentHandler::getForTitle( $this->oldTitle ); + + $out->enableOOUI(); + $fields = []; + + $fields[] = new OOUI\FieldLayout( + new MediaWiki\Widget\ComplexTitleInputWidget( [ + 'id' => 'wpNewTitle', + 'namespace' => [ + 'id' => 'wpNewTitleNs', + 'name' => 'wpNewTitleNs', + 'value' => $newTitle->getNamespace(), + 'exclude' => $immovableNamespaces, + ], + 'title' => [ + 'id' => 'wpNewTitleMain', + 'name' => 'wpNewTitleMain', + 'value' => $newTitle->getText(), + // Inappropriate, since we're expecting the user to input a non-existent page's title + 'suggestions' => false, + ], + 'infusable' => true, + ] ), + [ + 'label' => $this->msg( 'newtitle' )->text(), + 'align' => 'top', + ] + ); + + // 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 (or 255 UTF-8 bytes for old schema). + $conf = $this->getConfig(); + $oldCommentSchema = $conf->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD; + $fields[] = new OOUI\FieldLayout( + new OOUI\TextInputWidget( [ + 'name' => 'wpReason', + 'id' => 'wpReason', + 'maxLength' => $oldCommentSchema ? 200 : CommentStore::COMMENT_CHARACTER_LIMIT, + 'infusable' => true, + 'value' => $this->reason, + ] ), + [ + 'label' => $this->msg( 'movereason' )->text(), + 'align' => 'top', + ] + ); + + if ( $considerTalk ) { + $fields[] = new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => 'wpMovetalk', + 'id' => 'wpMovetalk', + 'value' => '1', + 'selected' => $this->moveTalk, + ] ), + [ + 'label' => $this->msg( 'movetalk' )->text(), + 'help' => new OOUI\HtmlSnippet( $this->msg( 'movepagetalktext' )->parseAsBlock() ), + 'align' => 'inline', + 'infusable' => true, + 'id' => 'wpMovetalk-field', + ] + ); + } + + if ( $user->isAllowed( 'suppressredirect' ) ) { + if ( $handler->supportsRedirects() ) { + $isChecked = $this->leaveRedirect; + $isDisabled = false; + } else { + $isChecked = false; + $isDisabled = true; + } + $fields[] = new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => 'wpLeaveRedirect', + 'id' => 'wpLeaveRedirect', + 'value' => '1', + 'selected' => $isChecked, + 'disabled' => $isDisabled, + ] ), + [ + 'label' => $this->msg( 'move-leave-redirect' )->text(), + 'align' => 'inline', + ] + ); + } + + if ( $hasRedirects ) { + $fields[] = new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => 'wpFixRedirects', + 'id' => 'wpFixRedirects', + 'value' => '1', + 'selected' => $this->fixRedirects, + ] ), + [ + 'label' => $this->msg( 'fix-double-redirects' )->text(), + 'align' => 'inline', + ] + ); + } + + if ( $canMoveSubpage ) { + $maximumMovedPages = $this->getConfig()->get( 'MaximumMovedPages' ); + $fields[] = new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => 'wpMovesubpages', + 'id' => 'wpMovesubpages', + 'value' => '1', + # Don't check the box if we only have talk subpages to + # move and we aren't moving the talk page. + 'selected' => $this->moveSubpages && ( $this->oldTitle->hasSubpages() || $this->moveTalk ), + ] ), + [ + 'label' => new OOUI\HtmlSnippet( + $this->msg( + ( $this->oldTitle->hasSubpages() + ? 'move-subpages' + : 'move-talk-subpages' ) + )->numParams( $maximumMovedPages )->params( $maximumMovedPages )->parse() + ), + 'align' => 'inline', + ] + ); + } + + # Don't allow watching if user is not logged in + if ( $user->isLoggedIn() ) { + $watchChecked = $user->isLoggedIn() && ( $this->watch || $user->getBoolOption( 'watchmoves' ) + || $user->isWatched( $this->oldTitle ) ); + $fields[] = new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => 'wpWatch', + 'id' => 'watch', # ew + 'value' => '1', + 'selected' => $watchChecked, + ] ), + [ + 'label' => $this->msg( 'move-watch' )->text(), + 'align' => 'inline', + ] + ); + } + + $hiddenFields = ''; + if ( $moveOverShared ) { + $hiddenFields .= Html::hidden( 'wpMoveOverSharedFile', '1' ); + } + + if ( $deleteAndMove ) { + $fields[] = new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => 'wpDeleteAndMove', + 'id' => 'wpDeleteAndMove', + 'value' => '1', + ] ), + [ + 'label' => $this->msg( 'delete_and_move_confirm' )->text(), + 'align' => 'inline', + ] + ); + } + + $fields[] = new OOUI\FieldLayout( + new OOUI\ButtonInputWidget( [ + 'name' => 'wpMove', + 'value' => $this->msg( 'movepagebtn' )->text(), + 'label' => $this->msg( 'movepagebtn' )->text(), + 'flags' => [ 'primary', 'progressive' ], + 'type' => 'submit', + ] ), + [ + 'align' => 'top', + ] + ); + + $fieldset = new OOUI\FieldsetLayout( [ + 'label' => $this->msg( 'move-page-legend' )->text(), + 'id' => 'mw-movepage-table', + 'items' => $fields, + ] ); + + $form = new OOUI\FormLayout( [ + 'method' => 'post', + 'action' => $this->getPageTitle()->getLocalURL( 'action=submit' ), + 'id' => 'movepage', + ] ); + $form->appendContent( + $fieldset, + new OOUI\HtmlSnippet( + $hiddenFields . + Html::hidden( 'wpOldTitle', $this->oldTitle->getPrefixedText() ) . + Html::hidden( 'wpEditToken', $user->getEditToken() ) + ) + ); + + $out->addHTML( + new OOUI\PanelLayout( [ + 'classes' => [ 'movepage-wrapper' ], + 'expanded' => false, + 'padded' => true, + 'framed' => true, + 'content' => $form, + ] ) + ); + + $this->showLogFragment( $this->oldTitle ); + $this->showSubpages( $this->oldTitle ); + } + + function doSubmit() { + $user = $this->getUser(); + + if ( $user->pingLimiter( 'move' ) ) { + throw new ThrottledError; + } + + $ot = $this->oldTitle; + $nt = $this->newTitle; + + # don't allow moving to pages with # in + if ( !$nt || $nt->hasFragment() ) { + $this->showForm( [ [ 'badtitletext' ] ] ); + + return; + } + + # Show a warning if the target file exists on a shared repo + if ( $nt->getNamespace() == NS_FILE + && !( $this->moveOverShared && $user->isAllowed( 'reupload-shared' ) ) + && !RepoGroup::singleton()->getLocalRepo()->findFile( $nt ) + && wfFindFile( $nt ) + ) { + $this->showForm( [ [ 'file-exists-sharedrepo' ] ] ); + + return; + } + + # Delete to make way if requested + if ( $this->deleteAndMove ) { + $permErrors = $nt->getUserPermissionsErrors( 'delete', $user ); + if ( count( $permErrors ) ) { + # Only show the first error + $this->showForm( $permErrors ); + + return; + } + + $reason = $this->msg( 'delete_and_move_reason', $ot )->inContentLanguage()->text(); + + // Delete an associated image if there is + if ( $nt->getNamespace() == NS_FILE ) { + $file = wfLocalFile( $nt ); + $file->load( File::READ_LATEST ); + if ( $file->exists() ) { + $file->delete( $reason, false, $user ); + } + } + + $error = ''; // passed by ref + $page = WikiPage::factory( $nt ); + $deleteStatus = $page->doDeleteArticleReal( $reason, false, 0, true, $error, $user ); + if ( !$deleteStatus->isGood() ) { + $this->showForm( $deleteStatus->getErrorsArray() ); + + return; + } + } + + $handler = ContentHandler::getForTitle( $ot ); + + if ( !$handler->supportsRedirects() ) { + $createRedirect = false; + } elseif ( $user->isAllowed( 'suppressredirect' ) ) { + $createRedirect = $this->leaveRedirect; + } else { + $createRedirect = true; + } + + # Do the actual move. + $mp = new MovePage( $ot, $nt ); + $valid = $mp->isValidMove(); + if ( !$valid->isOK() ) { + $this->showForm( $valid->getErrorsArray() ); + return; + } + + $permStatus = $mp->checkPermissions( $user, $this->reason ); + if ( !$permStatus->isOK() ) { + $this->showForm( $permStatus->getErrorsArray() ); + return; + } + + $status = $mp->move( $user, $this->reason, $createRedirect ); + if ( !$status->isOK() ) { + $this->showForm( $status->getErrorsArray() ); + return; + } + + if ( $this->getConfig()->get( 'FixDoubleRedirects' ) && $this->fixRedirects ) { + DoubleRedirectJob::fixRedirects( 'move', $ot, $nt ); + } + + $out = $this->getOutput(); + $out->setPageTitle( $this->msg( 'pagemovedsub' ) ); + + $linkRenderer = $this->getLinkRenderer(); + $oldLink = $linkRenderer->makeLink( + $ot, + null, + [ 'id' => 'movepage-oldlink' ], + [ 'redirect' => 'no' ] + ); + $newLink = $linkRenderer->makeKnownLink( + $nt, + null, + [ 'id' => 'movepage-newlink' ] + ); + $oldText = $ot->getPrefixedText(); + $newText = $nt->getPrefixedText(); + + if ( $ot->exists() ) { + // NOTE: we assume that if the old title exists, it's because it was re-created as + // a redirect to the new title. This is not safe, but what we did before was + // even worse: we just determined whether a redirect should have been created, + // and reported that it was created if it should have, without any checks. + // Also note that isRedirect() is unreliable because of T39209. + $msgName = 'movepage-moved-redirect'; + } else { + $msgName = 'movepage-moved-noredirect'; + } + + $out->addHTML( $this->msg( 'movepage-moved' )->rawParams( $oldLink, + $newLink )->params( $oldText, $newText )->parseAsBlock() ); + $out->addWikiMsg( $msgName ); + + // Avoid PHP 7.1 warning from passing $this by reference + $movePage = $this; + Hooks::run( 'SpecialMovepageAfterMove', [ &$movePage, &$ot, &$nt ] ); + + # Now we move extra pages we've been asked to move: subpages and talk + # pages. First, if the old page or the new page is a talk page, we + # can't move any talk pages: cancel that. + if ( $ot->isTalkPage() || $nt->isTalkPage() ) { + $this->moveTalk = false; + } + + if ( count( $ot->getUserPermissionsErrors( 'move-subpages', $user ) ) ) { + $this->moveSubpages = false; + } + + /** + * Next make a list of id's. This might be marginally less efficient + * than a more direct method, but this is not a highly performance-cri- + * tical code path and readable code is more important here. + * + * If the target namespace doesn't allow subpages, moving with subpages + * would mean that you couldn't move them back in one operation, which + * is bad. + * @todo FIXME: A specific error message should be given in this case. + */ + + // @todo FIXME: Use Title::moveSubpages() here + $dbr = wfGetDB( DB_MASTER ); + if ( $this->moveSubpages && ( + MWNamespace::hasSubpages( $nt->getNamespace() ) || ( + $this->moveTalk + && MWNamespace::hasSubpages( $nt->getTalkPage()->getNamespace() ) + ) + ) ) { + $conds = [ + 'page_title' . $dbr->buildLike( $ot->getDBkey() . '/', $dbr->anyString() ) + . ' OR page_title = ' . $dbr->addQuotes( $ot->getDBkey() ) + ]; + $conds['page_namespace'] = []; + if ( MWNamespace::hasSubpages( $nt->getNamespace() ) ) { + $conds['page_namespace'][] = $ot->getNamespace(); + } + if ( $this->moveTalk && + MWNamespace::hasSubpages( $nt->getTalkPage()->getNamespace() ) + ) { + $conds['page_namespace'][] = $ot->getTalkPage()->getNamespace(); + } + } elseif ( $this->moveTalk ) { + $conds = [ + 'page_namespace' => $ot->getTalkPage()->getNamespace(), + 'page_title' => $ot->getDBkey() + ]; + } else { + # Skip the query + $conds = null; + } + + $extraPages = []; + if ( !is_null( $conds ) ) { + $extraPages = TitleArray::newFromResult( + $dbr->select( 'page', + [ 'page_id', 'page_namespace', 'page_title' ], + $conds, + __METHOD__ + ) + ); + } + + $extraOutput = []; + $count = 1; + foreach ( $extraPages as $oldSubpage ) { + if ( $ot->equals( $oldSubpage ) || $nt->equals( $oldSubpage ) ) { + # Already did this one. + continue; + } + + $newPageName = preg_replace( + '#^' . preg_quote( $ot->getDBkey(), '#' ) . '#', + StringUtils::escapeRegexReplacement( $nt->getDBkey() ), # T23234 + $oldSubpage->getDBkey() + ); + + if ( $oldSubpage->isSubpage() && ( $ot->isTalkPage() xor $nt->isTalkPage() ) ) { + // Moving a subpage from a subject namespace to a talk namespace or vice-versa + $newNs = $nt->getNamespace(); + } elseif ( $oldSubpage->isTalkPage() ) { + $newNs = $nt->getTalkPage()->getNamespace(); + } else { + $newNs = $nt->getSubjectPage()->getNamespace(); + } + + # T16385: we need makeTitleSafe because the new page names may + # be longer than 255 characters. + $newSubpage = Title::makeTitleSafe( $newNs, $newPageName ); + if ( !$newSubpage ) { + $oldLink = $linkRenderer->makeKnownLink( $oldSubpage ); + $extraOutput[] = $this->msg( 'movepage-page-unmoved' )->rawParams( $oldLink ) + ->params( Title::makeName( $newNs, $newPageName ) )->escaped(); + continue; + } + + # This was copy-pasted from Renameuser, bleh. + if ( $newSubpage->exists() && !$oldSubpage->isValidMoveTarget( $newSubpage ) ) { + $link = $linkRenderer->makeKnownLink( $newSubpage ); + $extraOutput[] = $this->msg( 'movepage-page-exists' )->rawParams( $link )->escaped(); + } else { + $success = $oldSubpage->moveTo( $newSubpage, true, $this->reason, $createRedirect ); + + if ( $success === true ) { + if ( $this->fixRedirects ) { + DoubleRedirectJob::fixRedirects( 'move', $oldSubpage, $newSubpage ); + } + $oldLink = $linkRenderer->makeLink( + $oldSubpage, + null, + [], + [ 'redirect' => 'no' ] + ); + + $newLink = $linkRenderer->makeKnownLink( $newSubpage ); + $extraOutput[] = $this->msg( 'movepage-page-moved' ) + ->rawParams( $oldLink, $newLink )->escaped(); + ++$count; + + $maximumMovedPages = $this->getConfig()->get( 'MaximumMovedPages' ); + if ( $count >= $maximumMovedPages ) { + $extraOutput[] = $this->msg( 'movepage-max-pages' ) + ->numParams( $maximumMovedPages )->escaped(); + break; + } + } else { + $oldLink = $linkRenderer->makeKnownLink( $oldSubpage ); + $newLink = $linkRenderer->makeLink( $newSubpage ); + $extraOutput[] = $this->msg( 'movepage-page-unmoved' ) + ->rawParams( $oldLink, $newLink )->escaped(); + } + } + } + + if ( $extraOutput !== [] ) { + $out->addHTML( "<ul>\n<li>" . implode( "</li>\n<li>", $extraOutput ) . "</li>\n</ul>" ); + } + + # Deal with watches (we don't watch subpages) + WatchAction::doWatchOrUnwatch( $this->watch, $ot, $user ); + WatchAction::doWatchOrUnwatch( $this->watch, $nt, $user ); + + /** + * T163966 + * Increment user_editcount during page moves + */ + $user->incEditCount(); + } + + function showLogFragment( $title ) { + $moveLogPage = new LogPage( 'move' ); + $out = $this->getOutput(); + $out->addHTML( Xml::element( 'h2', null, $moveLogPage->getName()->text() ) ); + LogEventsList::showLogExtract( $out, 'move', $title ); + } + + /** + * Show subpages of the page being moved. Section is not shown if both current + * namespace does not support subpages and no talk subpages were found. + * + * @param Title $title Page being moved. + */ + function showSubpages( $title ) { + $nsHasSubpages = MWNamespace::hasSubpages( $title->getNamespace() ); + $subpages = $title->getSubpages(); + $count = $subpages instanceof TitleArray ? $subpages->count() : 0; + + $titleIsTalk = $title->isTalkPage(); + $subpagesTalk = $title->getTalkPage()->getSubpages(); + $countTalk = $subpagesTalk instanceof TitleArray ? $subpagesTalk->count() : 0; + $totalCount = $count + $countTalk; + + if ( !$nsHasSubpages && $countTalk == 0 ) { + return; + } + + $this->getOutput()->wrapWikiMsg( + '== $1 ==', + [ 'movesubpage', ( $titleIsTalk ? $count : $totalCount ) ] + ); + + if ( $nsHasSubpages ) { + $this->showSubpagesList( $subpages, $count, 'movesubpagetext', true ); + } + + if ( !$titleIsTalk && $countTalk > 0 ) { + $this->showSubpagesList( $subpagesTalk, $countTalk, 'movesubpagetalktext' ); + } + } + + function showSubpagesList( $subpages, $pagecount, $wikiMsg, $noSubpageMsg = false ) { + $out = $this->getOutput(); + + # No subpages. + if ( $pagecount == 0 && $noSubpageMsg ) { + $out->addWikiMsg( 'movenosubpage' ); + return; + } + + $out->addWikiMsg( $wikiMsg, $this->getLanguage()->formatNum( $pagecount ) ); + $out->addHTML( "<ul>\n" ); + + $linkBatch = new LinkBatch( $subpages ); + $linkBatch->setCaller( __METHOD__ ); + $linkBatch->execute(); + $linkRenderer = $this->getLinkRenderer(); + + foreach ( $subpages as $subpage ) { + $link = $linkRenderer->makeLink( $subpage ); + $out->addHTML( "<li>$link</li>\n" ); + } + $out->addHTML( "</ul>\n" ); + } + + /** + * 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/www/wiki/includes/specials/SpecialMyLanguage.php b/www/wiki/includes/specials/SpecialMyLanguage.php new file mode 100644 index 00000000..37d96f47 --- /dev/null +++ b/www/wiki/includes/specials/SpecialMyLanguage.php @@ -0,0 +1,138 @@ +<?php +/** + * Implements Special:MyLanguage + * + * 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 + * @author Niklas Laxström + * @author Siebrand Mazeland + * @copyright Copyright © 2010-2013 Niklas Laxström, Siebrand Mazeland + */ + +/** + * Unlisted special page just to redirect the user to the translated version of + * a page, if it exists. + * + * Usage: [[Special:MyLanguage/Page name|link text]] + * + * @since 1.24 + * @ingroup SpecialPage + */ +class SpecialMyLanguage extends RedirectSpecialArticle { + public function __construct() { + parent::__construct( 'MyLanguage' ); + } + + /** + * If the special page is a redirect, then get the Title object it redirects to. + * False otherwise. + * + * @param string|null $subpage + * @return Title + */ + public function getRedirect( $subpage ) { + $title = $this->findTitle( $subpage ); + // Go to the main page if given invalid title. + if ( !$title ) { + $title = Title::newMainPage(); + } + return $title; + } + + /** + * Assuming the user's interface language is fi. Given input Page, it + * returns Page/fi if it exists, otherwise Page. Given input Page/de, + * it returns Page/fi if it exists, otherwise Page/de if it exists, + * otherwise Page. + * + * @param string|null $subpage + * @return Title|null + */ + public function findTitle( $subpage ) { + // base = title without language code suffix + // provided = the title as it was given + $base = $provided = null; + if ( $subpage !== null ) { + $provided = Title::newFromText( $subpage ); + $base = $provided; + } + + if ( $provided && strpos( $subpage, '/' ) !== false ) { + $pos = strrpos( $subpage, '/' ); + $basepage = substr( $subpage, 0, $pos ); + $code = substr( $subpage, $pos + 1 ); + if ( strlen( $code ) && Language::isKnownLanguageTag( $code ) ) { + $base = Title::newFromText( $basepage ); + } + } + + if ( !$base ) { + // No subpage provided or base page does not exist + return null; + } + + if ( $base->isRedirect() ) { + $page = new WikiPage( $base ); + $base = $page->getRedirectTarget(); + } + + $uiCode = $this->getLanguage()->getCode(); + $wikiLangCode = $this->getConfig()->get( 'LanguageCode' ); + + if ( $uiCode === $wikiLangCode ) { + // Short circuit when the current UI language is the + // wiki's default language to avoid unnecessary page lookups. + return $base; + } + + // Check for a subpage in current UI language + $proposed = $base->getSubpage( $uiCode ); + if ( $proposed && $proposed->exists() ) { + return $proposed; + } + + if ( $provided !== $base && $provided->exists() ) { + // Explicit language code given and the page exists + return $provided; + } + + // Check for fallback languages specified by the UI language + $possibilities = Language::getFallbacksFor( $uiCode ); + foreach ( $possibilities as $lang ) { + if ( $lang !== $wikiLangCode ) { + $proposed = $base->getSubpage( $lang ); + if ( $proposed && $proposed->exists() ) { + return $proposed; + } + } + } + + // When all else has failed, return the base page + return $base; + } + + /** + * Target can identify a specific user's language preference. + * + * @see T109724 + * @since 1.27 + * @return bool + */ + public function personallyIdentifiableTarget() { + return true; + } +} diff --git a/www/wiki/includes/specials/SpecialMyRedirectPages.php b/www/wiki/includes/specials/SpecialMyRedirectPages.php new file mode 100644 index 00000000..4521a53f --- /dev/null +++ b/www/wiki/includes/specials/SpecialMyRedirectPages.php @@ -0,0 +1,185 @@ +<?php +/** + * Special pages that are used to get user independent links pointing to + * current user's pages (user page, talk page, contributions, etc.). + * This can let us cache a single copy of some generated content for all + * users or be linked in wikitext help pages. + * + * 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 + */ + +/** + * Special page pointing to current user's user page. + * + * @ingroup SpecialPage + */ +class SpecialMypage extends RedirectSpecialArticle { + public function __construct() { + parent::__construct( 'Mypage' ); + } + + /** + * @param string|null $subpage + * @return Title + */ + public function getRedirect( $subpage ) { + if ( $subpage === null || $subpage === '' ) { + return Title::makeTitle( NS_USER, $this->getUser()->getName() ); + } + + return Title::makeTitle( NS_USER, $this->getUser()->getName() . '/' . $subpage ); + } + + /** + * Target identifies a specific User. See T109724. + * + * @since 1.27 + * @return bool + */ + public function personallyIdentifiableTarget() { + return true; + } +} + +/** + * Special page pointing to current user's talk page. + * + * @ingroup SpecialPage + */ +class SpecialMytalk extends RedirectSpecialArticle { + public function __construct() { + parent::__construct( 'Mytalk' ); + } + + /** + * @param string|null $subpage + * @return Title + */ + public function getRedirect( $subpage ) { + if ( $subpage === null || $subpage === '' ) { + return Title::makeTitle( NS_USER_TALK, $this->getUser()->getName() ); + } + + return Title::makeTitle( NS_USER_TALK, $this->getUser()->getName() . '/' . $subpage ); + } + + /** + * Target identifies a specific User. See T109724. + * + * @since 1.27 + * @return bool + */ + public function personallyIdentifiableTarget() { + return true; + } +} + +/** + * Special page pointing to current user's contributions. + * + * @ingroup SpecialPage + */ +class SpecialMycontributions extends RedirectSpecialPage { + public function __construct() { + parent::__construct( 'Mycontributions' ); + $this->mAllowedRedirectParams = [ 'limit', 'namespace', 'tagfilter', + 'offset', 'dir', 'year', 'month', 'feed', 'deletedOnly', + 'nsInvert', 'associated', 'newOnly', 'topOnly' ]; + } + + /** + * @param string|null $subpage + * @return Title + */ + public function getRedirect( $subpage ) { + return SpecialPage::getTitleFor( 'Contributions', $this->getUser()->getName() ); + } + + /** + * Target identifies a specific User. See T109724. + * + * @since 1.27 + * @return bool + */ + public function personallyIdentifiableTarget() { + return true; + } +} + +/** + * Special page pointing to current user's uploaded files. + * + * @ingroup SpecialPage + */ +class SpecialMyuploads extends RedirectSpecialPage { + public function __construct() { + parent::__construct( 'Myuploads' ); + $this->mAllowedRedirectParams = [ 'limit', 'ilshowall', 'ilsearch' ]; + } + + /** + * @param string|null $subpage + * @return Title + */ + public function getRedirect( $subpage ) { + return SpecialPage::getTitleFor( 'Listfiles', $this->getUser()->getName() ); + } + + /** + * Target identifies a specific User. See T109724. + * + * @since 1.27 + * @return bool + */ + public function personallyIdentifiableTarget() { + return true; + } +} + +/** + * Special page pointing to current user's uploaded files (including old versions). + * + * @ingroup SpecialPage + */ +class SpecialAllMyUploads extends RedirectSpecialPage { + public function __construct() { + parent::__construct( 'AllMyUploads' ); + $this->mAllowedRedirectParams = [ 'limit', 'ilsearch' ]; + } + + /** + * @param string|null $subpage + * @return Title + */ + public function getRedirect( $subpage ) { + $this->mAddedRedirectParams['ilshowall'] = 1; + + return SpecialPage::getTitleFor( 'Listfiles', $this->getUser()->getName() ); + } + + /** + * Target identifies a specific User. See T109724. + * + * @since 1.27 + * @return bool + */ + public function personallyIdentifiableTarget() { + return true; + } +} diff --git a/www/wiki/includes/specials/SpecialNewimages.php b/www/wiki/includes/specials/SpecialNewimages.php new file mode 100644 index 00000000..693b8aa9 --- /dev/null +++ b/www/wiki/includes/specials/SpecialNewimages.php @@ -0,0 +1,230 @@ +<?php +/** + * Implements Special:Newimages + * + * 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 + */ + +class SpecialNewFiles extends IncludableSpecialPage { + /** @var FormOptions */ + protected $opts; + + /** @var string[] */ + protected $mediaTypes; + + public function __construct() { + parent::__construct( 'Newimages' ); + } + + public function execute( $par ) { + $context = new DerivativeContext( $this->getContext() ); + + $this->setHeaders(); + $this->outputHeader(); + $mimeAnalyzer = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer(); + $this->mediaTypes = $mimeAnalyzer->getMediaTypes(); + + $out = $this->getOutput(); + $this->addHelpLink( 'Help:New images' ); + + $opts = new FormOptions(); + + $opts->add( 'like', '' ); + $opts->add( 'user', '' ); + $opts->add( 'showbots', false ); + $opts->add( 'newbies', false ); + $opts->add( 'hidepatrolled', false ); + $opts->add( 'mediatype', $this->mediaTypes ); + $opts->add( 'limit', 50 ); + $opts->add( 'offset', '' ); + $opts->add( 'start', '' ); + $opts->add( 'end', '' ); + + $opts->fetchValuesFromRequest( $this->getRequest() ); + + if ( $par !== null ) { + $opts->setValue( is_numeric( $par ) ? 'limit' : 'like', $par ); + } + + // If start date comes after end date chronologically, swap them. + // They are swapped in the interface by JS. + $start = $opts->getValue( 'start' ); + $end = $opts->getValue( 'end' ); + if ( $start !== '' && $end !== '' && $start > $end ) { + $temp = $end; + $end = $start; + $start = $temp; + + $opts->setValue( 'start', $start, true ); + $opts->setValue( 'end', $end, true ); + + // also swap values in request object, which is used by HTMLForm + // to pre-populate the fields with the previous input + $request = $context->getRequest(); + $context->setRequest( new DerivativeRequest( + $request, + [ 'start' => $start, 'end' => $end ] + $request->getValues(), + $request->wasPosted() + ) ); + } + + // if all media types have been selected, wipe out the array to prevent + // the pointless IN(...) query condition (which would have no effect + // because every possible type has been selected) + $missingMediaTypes = array_diff( $this->mediaTypes, $opts->getValue( 'mediatype' ) ); + if ( empty( $missingMediaTypes ) ) { + $opts->setValue( 'mediatype', [] ); + } + + $opts->validateIntBounds( 'limit', 0, 500 ); + + $this->opts = $opts; + + if ( !$this->including() ) { + $this->setTopText(); + $this->buildForm( $context ); + } + + $pager = new NewFilesPager( $context, $opts ); + + $out->addHTML( $pager->getBody() ); + if ( !$this->including() ) { + $out->addHTML( $pager->getNavigationBar() ); + } + } + + protected function buildForm( IContextSource $context ) { + $mediaTypesText = array_map( function ( $type ) { + // mediastatistics-header-unknown, mediastatistics-header-bitmap, + // mediastatistics-header-drawing, mediastatistics-header-audio, + // mediastatistics-header-video, mediastatistics-header-multimedia, + // mediastatistics-header-office, mediastatistics-header-text, + // mediastatistics-header-executable, mediastatistics-header-archive, + // mediastatistics-header-3d, + return $this->msg( 'mediastatistics-header-' . strtolower( $type ) )->text(); + }, $this->mediaTypes ); + $mediaTypesOptions = array_combine( $mediaTypesText, $this->mediaTypes ); + ksort( $mediaTypesOptions ); + + $formDescriptor = [ + 'like' => [ + 'type' => 'text', + 'label-message' => 'newimages-label', + 'name' => 'like', + ], + + 'user' => [ + 'type' => 'text', + 'label-message' => 'newimages-user', + 'name' => 'user', + ], + + 'newbies' => [ + 'type' => 'check', + 'label-message' => 'newimages-newbies', + 'name' => 'newbies', + ], + + 'showbots' => [ + 'type' => 'check', + 'label-message' => 'newimages-showbots', + 'name' => 'showbots', + ], + + 'hidepatrolled' => [ + 'type' => 'check', + 'label-message' => 'newimages-hidepatrolled', + 'name' => 'hidepatrolled', + ], + + 'mediatype' => [ + 'type' => 'multiselect', + 'flatlist' => true, + 'name' => 'mediatype', + 'label-message' => 'newimages-mediatype', + 'options' => $mediaTypesOptions, + 'default' => $this->mediaTypes, + ], + + 'limit' => [ + 'type' => 'hidden', + 'default' => $this->opts->getValue( 'limit' ), + 'name' => 'limit', + ], + + 'offset' => [ + 'type' => 'hidden', + 'default' => $this->opts->getValue( 'offset' ), + 'name' => 'offset', + ], + + 'start' => [ + 'type' => 'date', + 'label-message' => 'date-range-from', + 'name' => 'start', + ], + + 'end' => [ + 'type' => 'date', + 'label-message' => 'date-range-to', + 'name' => 'end', + ], + ]; + + if ( $this->getConfig()->get( 'MiserMode' ) ) { + unset( $formDescriptor['like'] ); + } + + if ( !$this->getUser()->useFilePatrol() ) { + unset( $formDescriptor['hidepatrolled'] ); + } + + HTMLForm::factory( 'ooui', $formDescriptor, $context ) + // For the 'multiselect' field values to be preserved on submit + ->setFormIdentifier( 'specialnewimages' ) + ->setWrapperLegendMsg( 'newimages-legend' ) + ->setSubmitTextMsg( 'ilsubmit' ) + ->setMethod( 'get' ) + ->prepareForm() + ->displayForm( false ); + } + + protected function getGroupName() { + return 'changes'; + } + + /** + * Send the text to be displayed above the options + */ + function setTopText() { + global $wgContLang; + + $message = $this->msg( 'newimagestext' )->inContentLanguage(); + if ( !$message->isDisabled() ) { + $this->getOutput()->addWikiText( + Html::rawElement( 'p', + [ 'lang' => $wgContLang->getHtmlCode(), 'dir' => $wgContLang->getDir() ], + "\n" . $message->plain() . "\n" + ), + /* $lineStart */ false, + /* $interface */ false + ); + } + } +} diff --git a/www/wiki/includes/specials/SpecialNewpages.php b/www/wiki/includes/specials/SpecialNewpages.php new file mode 100644 index 00000000..46d5276c --- /dev/null +++ b/www/wiki/includes/specials/SpecialNewpages.php @@ -0,0 +1,518 @@ +<?php +/** + * Implements Special:Newpages + * + * 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 + */ + +/** + * A special page that list newly created pages + * + * @ingroup SpecialPage + */ +class SpecialNewpages extends IncludableSpecialPage { + /** + * @var FormOptions + */ + protected $opts; + protected $customFilters; + + protected $showNavigation = false; + + public function __construct() { + parent::__construct( 'Newpages' ); + } + + protected function setup( $par ) { + // Options + $opts = new FormOptions(); + $this->opts = $opts; // bind + $opts->add( 'hideliu', false ); + $opts->add( 'hidepatrolled', $this->getUser()->getBoolOption( 'newpageshidepatrolled' ) ); + $opts->add( 'hidebots', false ); + $opts->add( 'hideredirs', true ); + $opts->add( 'limit', $this->getUser()->getIntOption( 'rclimit' ) ); + $opts->add( 'offset', '' ); + $opts->add( 'namespace', '0' ); + $opts->add( 'username', '' ); + $opts->add( 'feed', '' ); + $opts->add( 'tagfilter', '' ); + $opts->add( 'invert', false ); + $opts->add( 'size-mode', 'max' ); + $opts->add( 'size', 0 ); + + $this->customFilters = []; + Hooks::run( 'SpecialNewPagesFilters', [ $this, &$this->customFilters ] ); + foreach ( $this->customFilters as $key => $params ) { + $opts->add( $key, $params['default'] ); + } + + // Set values + $opts->fetchValuesFromRequest( $this->getRequest() ); + if ( $par ) { + $this->parseParams( $par ); + } + + // Validate + $opts->validateIntBounds( 'limit', 0, 5000 ); + } + + protected function parseParams( $par ) { + $bits = preg_split( '/\s*,\s*/', trim( $par ) ); + foreach ( $bits as $bit ) { + if ( 'shownav' == $bit ) { + $this->showNavigation = true; + } + if ( 'hideliu' === $bit ) { + $this->opts->setValue( 'hideliu', true ); + } + if ( 'hidepatrolled' == $bit ) { + $this->opts->setValue( 'hidepatrolled', true ); + } + if ( 'hidebots' == $bit ) { + $this->opts->setValue( 'hidebots', true ); + } + if ( 'showredirs' == $bit ) { + $this->opts->setValue( 'hideredirs', false ); + } + if ( is_numeric( $bit ) ) { + $this->opts->setValue( 'limit', intval( $bit ) ); + } + + $m = []; + if ( preg_match( '/^limit=(\d+)$/', $bit, $m ) ) { + $this->opts->setValue( 'limit', intval( $m[1] ) ); + } + // PG offsets not just digits! + if ( preg_match( '/^offset=([^=]+)$/', $bit, $m ) ) { + $this->opts->setValue( 'offset', intval( $m[1] ) ); + } + if ( preg_match( '/^username=(.*)$/', $bit, $m ) ) { + $this->opts->setValue( 'username', $m[1] ); + } + if ( preg_match( '/^namespace=(.*)$/', $bit, $m ) ) { + $ns = $this->getLanguage()->getNsIndex( $m[1] ); + if ( $ns !== false ) { + $this->opts->setValue( 'namespace', $ns ); + } + } + } + } + + /** + * Show a form for filtering namespace and username + * + * @param string $par + */ + public function execute( $par ) { + $out = $this->getOutput(); + + $this->setHeaders(); + $this->outputHeader(); + + $this->showNavigation = !$this->including(); // Maybe changed in setup + $this->setup( $par ); + + $this->addHelpLink( 'Help:New pages' ); + + if ( !$this->including() ) { + // Settings + $this->form(); + + $feedType = $this->opts->getValue( 'feed' ); + if ( $feedType ) { + $this->feed( $feedType ); + + return; + } + + $allValues = $this->opts->getAllValues(); + unset( $allValues['feed'] ); + $out->setFeedAppendQuery( wfArrayToCgi( $allValues ) ); + } + + $pager = new NewPagesPager( $this, $this->opts ); + $pager->mLimit = $this->opts->getValue( 'limit' ); + $pager->mOffset = $this->opts->getValue( 'offset' ); + + if ( $pager->getNumRows() ) { + $navigation = ''; + if ( $this->showNavigation ) { + $navigation = $pager->getNavigationBar(); + } + $out->addHTML( $navigation . $pager->getBody() . $navigation ); + } else { + $out->addWikiMsg( 'specialpage-empty' ); + } + } + + protected function filterLinks() { + // show/hide links + $showhide = [ $this->msg( 'show' )->escaped(), $this->msg( 'hide' )->escaped() ]; + + // Option value -> message mapping + $filters = [ + 'hideliu' => 'rcshowhideliu', + 'hidepatrolled' => 'rcshowhidepatr', + 'hidebots' => 'rcshowhidebots', + 'hideredirs' => 'whatlinkshere-hideredirs' + ]; + foreach ( $this->customFilters as $key => $params ) { + $filters[$key] = $params['msg']; + } + + // Disable some if needed + if ( !User::groupHasPermission( '*', 'createpage' ) ) { + unset( $filters['hideliu'] ); + } + if ( !$this->getUser()->useNPPatrol() ) { + unset( $filters['hidepatrolled'] ); + } + + $links = []; + $changed = $this->opts->getChangedValues(); + unset( $changed['offset'] ); // Reset offset if query type changes + + $self = $this->getPageTitle(); + $linkRenderer = $this->getLinkRenderer(); + foreach ( $filters as $key => $msg ) { + $onoff = 1 - $this->opts->getValue( $key ); + $link = $linkRenderer->makeLink( + $self, + new HtmlArmor( $showhide[$onoff] ), + [], + [ $key => $onoff ] + $changed + ); + $links[$key] = $this->msg( $msg )->rawParams( $link )->escaped(); + } + + return $this->getLanguage()->pipeList( $links ); + } + + protected function form() { + $out = $this->getOutput(); + $out->addModules( 'mediawiki.userSuggest' ); + + // Consume values + $this->opts->consumeValue( 'offset' ); // don't carry offset, DWIW + $namespace = $this->opts->consumeValue( 'namespace' ); + $username = $this->opts->consumeValue( 'username' ); + $tagFilterVal = $this->opts->consumeValue( 'tagfilter' ); + $nsinvert = $this->opts->consumeValue( 'invert' ); + + $size = $this->opts->consumeValue( 'size' ); + $max = $this->opts->consumeValue( 'size-mode' ) === 'max'; + + // Check username input validity + $ut = Title::makeTitleSafe( NS_USER, $username ); + $userText = $ut ? $ut->getText() : ''; + + // Store query values in hidden fields so that form submission doesn't lose them + $hidden = []; + foreach ( $this->opts->getUnconsumedValues() as $key => $value ) { + $hidden[] = Html::hidden( $key, $value ); + } + $hidden = implode( "\n", $hidden ); + + $form = [ + 'namespace' => [ + 'type' => 'namespaceselect', + 'name' => 'namespace', + 'label-message' => 'namespace', + 'default' => $namespace, + ], + 'nsinvert' => [ + 'type' => 'check', + 'name' => 'invert', + 'label-message' => 'invert', + 'default' => $nsinvert, + 'tooltip' => 'invert', + ], + 'tagFilter' => [ + 'type' => 'tagfilter', + 'name' => 'tagfilter', + 'label-raw' => $this->msg( 'tag-filter' )->parse(), + 'default' => $tagFilterVal, + ], + 'username' => [ + 'type' => 'text', + 'name' => 'username', + 'label-message' => 'newpages-username', + 'default' => $userText, + 'id' => 'mw-np-username', + 'size' => 30, + 'cssclass' => 'mw-autocomplete-user', // used by mediawiki.userSuggest + ], + 'size' => [ + 'type' => 'sizefilter', + 'name' => 'size', + 'default' => -$max * $size, + ], + ]; + + $htmlForm = new HTMLForm( $form, $this->getContext() ); + + $htmlForm->setSubmitText( $this->msg( 'newpages-submit' )->text() ); + $htmlForm->setSubmitProgressive(); + // The form should be visible on each request (inclusive requests with submitted forms), so + // return always false here. + $htmlForm->setSubmitCallback( + function () { + return false; + } + ); + $htmlForm->setMethod( 'get' ); + $htmlForm->setWrapperLegend( true ); + $htmlForm->setWrapperLegendMsg( 'newpages' ); + $htmlForm->addFooterText( Html::rawElement( + 'div', + null, + $this->filterLinks() + ) ); + $htmlForm->show(); + } + + /** + * @param stdClass $result Result row from recent changes + * @param Title $title + * @return bool|Revision + */ + protected function revisionFromRcResult( stdClass $result, Title $title ) { + return new Revision( [ + 'comment' => CommentStore::getStore()->getComment( 'rc_comment', $result )->text, + 'deleted' => $result->rc_deleted, + 'user_text' => $result->rc_user_text, + 'user' => $result->rc_user, + 'actor' => $result->rc_actor, + ], 0, $title ); + } + + /** + * Format a row, providing the timestamp, links to the page/history, + * size, user links, and a comment + * + * @param object $result Result row + * @return string + */ + public function formatRow( $result ) { + $title = Title::newFromRow( $result ); + + // Revision deletion works on revisions, + // so cast our recent change row to a revision row. + $rev = $this->revisionFromRcResult( $result, $title ); + + $classes = []; + $attribs = [ 'data-mw-revid' => $result->rev_id ]; + + $lang = $this->getLanguage(); + $dm = $lang->getDirMark(); + + $spanTime = Html::element( 'span', [ 'class' => 'mw-newpages-time' ], + $lang->userTimeAndDate( $result->rc_timestamp, $this->getUser() ) + ); + $linkRenderer = $this->getLinkRenderer(); + $time = $linkRenderer->makeKnownLink( + $title, + new HtmlArmor( $spanTime ), + [], + [ 'oldid' => $result->rc_this_oldid ] + ); + + $query = $title->isRedirect() ? [ 'redirect' => 'no' ] : []; + + $plink = $linkRenderer->makeKnownLink( + $title, + null, + [ 'class' => 'mw-newpages-pagename' ], + $query + ); + $histLink = $linkRenderer->makeKnownLink( + $title, + $this->msg( 'hist' )->text(), + [], + [ 'action' => 'history' ] + ); + $hist = Html::rawElement( 'span', [ 'class' => 'mw-newpages-history' ], + $this->msg( 'parentheses' )->rawParams( $histLink )->escaped() ); + + $length = Html::rawElement( + 'span', + [ 'class' => 'mw-newpages-length' ], + $this->msg( 'brackets' )->rawParams( + $this->msg( 'nbytes' )->numParams( $result->length )->escaped() + )->escaped() + ); + + $ulink = Linker::revUserTools( $rev ); + $comment = Linker::revComment( $rev ); + + if ( $this->patrollable( $result ) ) { + $classes[] = 'not-patrolled'; + } + + # Add a class for zero byte pages + if ( $result->length == 0 ) { + $classes[] = 'mw-newpages-zero-byte-page'; + } + + # Tags, if any. + if ( isset( $result->ts_tags ) ) { + list( $tagDisplay, $newClasses ) = ChangeTags::formatSummaryRow( + $result->ts_tags, + 'newpages', + $this->getContext() + ); + $classes = array_merge( $classes, $newClasses ); + } else { + $tagDisplay = ''; + } + + # Display the old title if the namespace/title has been changed + $oldTitleText = ''; + $oldTitle = Title::makeTitle( $result->rc_namespace, $result->rc_title ); + + if ( !$title->equals( $oldTitle ) ) { + $oldTitleText = $oldTitle->getPrefixedText(); + $oldTitleText = Html::rawElement( + 'span', + [ 'class' => 'mw-newpages-oldtitle' ], + $this->msg( 'rc-old-title' )->params( $oldTitleText )->escaped() + ); + } + + $ret = "{$time} {$dm}{$plink} {$hist} {$dm}{$length} {$dm}{$ulink} {$comment} " + . "{$tagDisplay} {$oldTitleText}"; + + // Let extensions add data + Hooks::run( 'NewPagesLineEnding', [ $this, &$ret, $result, &$classes, &$attribs ] ); + $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] ); + + if ( count( $classes ) ) { + $attribs['class'] = implode( ' ', $classes ); + } + + return Html::rawElement( 'li', $attribs, $ret ) . "\n"; + } + + /** + * Should a specific result row provide "patrollable" links? + * + * @param object $result Result row + * @return bool + */ + protected function patrollable( $result ) { + return ( $this->getUser()->useNPPatrol() && !$result->rc_patrolled ); + } + + /** + * Output a subscription feed listing recent edits to this page. + * + * @param string $type + */ + protected function feed( $type ) { + if ( !$this->getConfig()->get( 'Feed' ) ) { + $this->getOutput()->addWikiMsg( 'feed-unavailable' ); + + return; + } + + $feedClasses = $this->getConfig()->get( 'FeedClasses' ); + if ( !isset( $feedClasses[$type] ) ) { + $this->getOutput()->addWikiMsg( 'feed-invalid' ); + + return; + } + + $feed = new $feedClasses[$type]( + $this->feedTitle(), + $this->msg( 'tagline' )->text(), + $this->getPageTitle()->getFullURL() + ); + + $pager = new NewPagesPager( $this, $this->opts ); + $limit = $this->opts->getValue( 'limit' ); + $pager->mLimit = min( $limit, $this->getConfig()->get( 'FeedLimit' ) ); + + $feed->outHeader(); + if ( $pager->getNumRows() > 0 ) { + foreach ( $pager->mResult as $row ) { + $feed->outItem( $this->feedItem( $row ) ); + } + } + $feed->outFooter(); + } + + protected function feedTitle() { + $desc = $this->getDescription(); + $code = $this->getConfig()->get( 'LanguageCode' ); + $sitename = $this->getConfig()->get( 'Sitename' ); + + return "$sitename - $desc [$code]"; + } + + protected function feedItem( $row ) { + $title = Title::makeTitle( intval( $row->rc_namespace ), $row->rc_title ); + if ( $title ) { + $date = $row->rc_timestamp; + $comments = $title->getTalkPage()->getFullURL(); + + return new FeedItem( + $title->getPrefixedText(), + $this->feedItemDesc( $row ), + $title->getFullURL(), + $date, + $this->feedItemAuthor( $row ), + $comments + ); + } else { + return null; + } + } + + protected function feedItemAuthor( $row ) { + return isset( $row->rc_user_text ) ? $row->rc_user_text : ''; + } + + protected function feedItemDesc( $row ) { + $revision = Revision::newFromId( $row->rev_id ); + if ( !$revision ) { + return ''; + } + + $content = $revision->getContent(); + if ( $content === null ) { + return ''; + } + + // XXX: include content model/type in feed item? + return '<p>' . htmlspecialchars( $revision->getUserText() ) . + $this->msg( 'colon-separator' )->inContentLanguage()->escaped() . + htmlspecialchars( FeedItem::stripComment( $revision->getComment() ) ) . + "</p>\n<hr />\n<div>" . + nl2br( htmlspecialchars( $content->serialize() ) ) . "</div>"; + } + + protected function getGroupName() { + return 'changes'; + } + + protected function getCacheTTL() { + return 60 * 5; + } +} diff --git a/www/wiki/includes/specials/SpecialPageData.php b/www/wiki/includes/specials/SpecialPageData.php new file mode 100644 index 00000000..978efa7f --- /dev/null +++ b/www/wiki/includes/specials/SpecialPageData.php @@ -0,0 +1,107 @@ +<?php +/** + * Special page to act as an endpoint for accessing raw page data. + * + * 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 + */ + +/** + * Special page to act as an endpoint for accessing raw page data. + * The web server should generally be configured to make this accessible via a canonical URL/URI, + * such as <http://my.domain.org/data/main/Foo>. + * + * @class + * @ingroup SpecialPage + */ +class SpecialPageData extends SpecialPage { + + /** + * @var PageDataRequestHandler|null + */ + private $requestHandler = null; + + public function __construct() { + parent::__construct( 'PageData' ); + } + + /** + * Sets the request handler to be used by the special page. + * May be used when a particular instance of PageDataRequestHandler is already + * known, e.g. during testing. + * + * If no request handler is set using this method, a default handler is created + * on demand by initDependencies(). + * + * @param PageDataRequestHandler $requestHandler + */ + public function setRequestHandler( PageDataRequestHandler $requestHandler ) { + $this->requestHandler = $requestHandler; + } + + /** + * Initialize any un-initialized members from global context. + * In particular, this initializes $this->requestHandler + */ + protected function initDependencies() { + if ( $this->requestHandler === null ) { + $this->requestHandler = $this->newDefaultRequestHandler(); + } + } + + /** + * Creates a PageDataRequestHandler based on global defaults. + * + * @return PageDataRequestHandler + */ + private function newDefaultRequestHandler() { + return new PageDataRequestHandler(); + } + + /** + * @see SpecialWikibasePage::execute + * + * @param string|null $subPage + * + * @throws HttpError + */ + public function execute( $subPage ) { + $this->initDependencies(); + + // If there is no title, show an HTML form + // TODO: Don't do this if HTML is not acceptable according to HTTP headers. + if ( !$this->requestHandler->canHandleRequest( $subPage, $this->getRequest() ) ) { + $this->showForm(); + return; + } + + $this->requestHandler->handleRequest( $subPage, $this->getRequest(), $this->getOutput() ); + } + + /** + * Shows an informative page to the user; Called when there is no page to output. + */ + public function showForm() { + $this->getOutput()->showErrorPage( 'pagedata-title', 'pagedata-text' ); + } + + public function isListed() { + // Do not list this page in Special:SpecialPages + return false; + } + +} diff --git a/www/wiki/includes/specials/SpecialPageLanguage.php b/www/wiki/includes/specials/SpecialPageLanguage.php new file mode 100644 index 00000000..a68f08fd --- /dev/null +++ b/www/wiki/includes/specials/SpecialPageLanguage.php @@ -0,0 +1,299 @@ +<?php +/** + * Implements Special:PageLanguage + * + * 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 + * @author Kunal Grover + * @since 1.24 + */ + +/** + * Special page for changing the content language of a page + * + * @ingroup SpecialPage + */ +class SpecialPageLanguage extends FormSpecialPage { + /** + * @var string URL to go to if language change successful + */ + private $goToUrl; + + public function __construct() { + parent::__construct( 'PageLanguage', 'pagelang' ); + } + + public function doesWrites() { + return true; + } + + protected function preText() { + $this->getOutput()->addModules( 'mediawiki.special.pageLanguage' ); + } + + protected function getFormFields() { + // Get default from the subpage of Special page + $defaultName = $this->par; + $title = $defaultName ? Title::newFromText( $defaultName ) : null; + if ( $title ) { + $defaultPageLanguage = + ContentHandler::getForTitle( $title )->getPageLanguage( $title ); + $hasCustomLanguageSet = !$defaultPageLanguage->equals( $title->getPageLanguage() ); + } else { + $hasCustomLanguageSet = false; + } + + $page = []; + $page['pagename'] = [ + 'type' => 'title', + 'label-message' => 'pagelang-name', + 'default' => $title ? $title->getPrefixedText() : $defaultName, + 'autofocus' => $defaultName === null, + 'exists' => true, + ]; + + // Options for whether to use the default language or select language + $selectoptions = [ + (string)$this->msg( 'pagelang-use-default' )->escaped() => 1, + (string)$this->msg( 'pagelang-select-lang' )->escaped() => 2, + ]; + $page['selectoptions'] = [ + 'id' => 'mw-pl-options', + 'type' => 'radio', + 'options' => $selectoptions, + 'default' => $hasCustomLanguageSet ? 2 : 1 + ]; + + // Building a language selector + $userLang = $this->getLanguage()->getCode(); + $languages = Language::fetchLanguageNames( $userLang, 'mwfile' ); + ksort( $languages ); + $options = []; + foreach ( $languages as $code => $name ) { + $options["$code - $name"] = $code; + } + + $page['language'] = [ + 'id' => 'mw-pl-languageselector', + 'cssclass' => 'mw-languageselector', + 'type' => 'select', + 'options' => $options, + 'label-message' => 'pagelang-language', + 'default' => $title ? + $title->getPageLanguage()->getCode() : + $this->getConfig()->get( 'LanguageCode' ), + ]; + + // Allow user to enter a comment explaining the change + $page['reason'] = [ + 'type' => 'text', + 'label-message' => 'pagelang-reason' + ]; + + return $page; + } + + protected function postText() { + if ( $this->par ) { + return $this->showLogFragment( $this->par ); + } + return ''; + } + + protected function getDisplayFormat() { + return 'ooui'; + } + + public function alterForm( HTMLForm $form ) { + Hooks::run( 'LanguageSelector', [ $this->getOutput(), 'mw-languageselector' ] ); + $form->setSubmitTextMsg( 'pagelang-submit' ); + } + + /** + * + * @param array $data + * @return Status + */ + public function onSubmit( array $data ) { + $pageName = $data['pagename']; + + // Check if user wants to use default language + if ( $data['selectoptions'] == 1 ) { + $newLanguage = 'default'; + } else { + $newLanguage = $data['language']; + } + + try { + $title = Title::newFromTextThrow( $pageName ); + } catch ( MalformedTitleException $ex ) { + return Status::newFatal( $ex->getMessageObject() ); + } + + // Check permissions and make sure the user has permission to edit the page + $errors = $title->getUserPermissionsErrors( 'edit', $this->getUser() ); + + if ( $errors ) { + $out = $this->getOutput(); + $wikitext = $out->formatPermissionsErrorMessage( $errors ); + // Hack to get our wikitext parsed + return Status::newFatal( new RawMessage( '$1', [ $wikitext ] ) ); + } + + // Url to redirect to after the operation + $this->goToUrl = $title->getFullUrlForRedirect( + $title->isRedirect() ? [ 'redirect' => 'no' ] : [] + ); + + return self::changePageLanguage( + $this->getContext(), + $title, + $newLanguage, + $data['reason'] === null ? '' : $data['reason'] + ); + } + + /** + * @param IContextSource $context + * @param Title $title + * @param string $newLanguage Language code + * @param string $reason Reason for the change + * @param array $tags Change tags to apply to the log entry + * @return Status + */ + public static function changePageLanguage( IContextSource $context, Title $title, + $newLanguage, $reason, array $tags = [] ) { + // Get the default language for the wiki + $defLang = $context->getConfig()->get( 'LanguageCode' ); + + $pageId = $title->getArticleID(); + + // Check if article exists + if ( !$pageId ) { + return Status::newFatal( + 'pagelang-nonexistent-page', + wfEscapeWikiText( $title->getPrefixedText() ) + ); + } + + // Load the page language from DB + $dbw = wfGetDB( DB_MASTER ); + $oldLanguage = $dbw->selectField( + 'page', + 'page_lang', + [ 'page_id' => $pageId ], + __METHOD__ + ); + + // Check if user wants to use the default language + if ( $newLanguage === 'default' ) { + $newLanguage = null; + } + + // No change in language + if ( $newLanguage === $oldLanguage ) { + // Check if old language does not exist + if ( !$oldLanguage ) { + return Status::newFatal( ApiMessage::create( + [ + 'pagelang-unchanged-language-default', + wfEscapeWikiText( $title->getPrefixedText() ) + ], + 'pagelang-unchanged-language' + ) ); + } + return Status::newFatal( + 'pagelang-unchanged-language', + wfEscapeWikiText( $title->getPrefixedText() ), + $oldLanguage + ); + } + + // Hardcoded [def] if the language is set to null + $logOld = $oldLanguage ? $oldLanguage : $defLang . '[def]'; + $logNew = $newLanguage ? $newLanguage : $defLang . '[def]'; + + // Writing new page language to database + $dbw->update( + 'page', + [ 'page_lang' => $newLanguage ], + [ + 'page_id' => $pageId, + 'page_lang' => $oldLanguage + ], + __METHOD__ + ); + + if ( !$dbw->affectedRows() ) { + return Status::newFatal( 'pagelang-db-failed' ); + } + + // Logging change of language + $logParams = [ + '4::oldlanguage' => $logOld, + '5::newlanguage' => $logNew + ]; + $entry = new ManualLogEntry( 'pagelang', 'pagelang' ); + $entry->setPerformer( $context->getUser() ); + $entry->setTarget( $title ); + $entry->setParameters( $logParams ); + $entry->setComment( $reason ); + $entry->setTags( $tags ); + + $logid = $entry->insert(); + $entry->publish( $logid ); + + // Force re-render so that language-based content (parser functions etc.) gets updated + $title->invalidateCache(); + + return Status::newGood( (object)[ + 'oldLanguage' => $logOld, + 'newLanguage' => $logNew, + 'logId' => $logid, + ] ); + } + + public function onSuccess() { + // Success causes a redirect + $this->getOutput()->redirect( $this->goToUrl ); + } + + function showLogFragment( $title ) { + $moveLogPage = new LogPage( 'pagelang' ); + $out1 = Xml::element( 'h2', null, $moveLogPage->getName()->text() ); + $out2 = ''; + LogEventsList::showLogExtract( $out2, 'pagelang', $title ); + return $out1 . $out2; + } + + /** + * 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/www/wiki/includes/specials/SpecialPagesWithProp.php b/www/wiki/includes/specials/SpecialPagesWithProp.php new file mode 100644 index 00000000..34fcc78c --- /dev/null +++ b/www/wiki/includes/specials/SpecialPagesWithProp.php @@ -0,0 +1,240 @@ +<?php +/** + * Implements Special:PagesWithProp + * + * 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 + * + * @since 1.21 + * @file + * @ingroup SpecialPage + */ + +/** + * Special:PagesWithProp to search the page_props table + * @ingroup SpecialPage + * @since 1.21 + */ +class SpecialPagesWithProp extends QueryPage { + + /** + * @var string|null + */ + private $propName = null; + + /** + * @var string[]|null + */ + private $existingPropNames = null; + + /** + * @var bool + */ + private $reverse = false; + + /** + * @var bool + */ + private $sortByValue = false; + + function __construct( $name = 'PagesWithProp' ) { + parent::__construct( $name ); + } + + function isCacheable() { + return false; + } + + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + $this->getOutput()->addModuleStyles( 'mediawiki.special.pagesWithProp' ); + + $request = $this->getRequest(); + $propname = $request->getVal( 'propname', $par ); + $this->reverse = $request->getBool( 'reverse' ); + $this->sortByValue = $request->getBool( 'sortbyvalue' ); + + $propnames = $this->getExistingPropNames(); + + $form = HTMLForm::factory( 'ooui', [ + 'propname' => [ + 'type' => 'combobox', + 'name' => 'propname', + 'options' => $propnames, + 'default' => $propname, + 'label-message' => 'pageswithprop-prop', + 'required' => true, + ], + 'reverse' => [ + 'type' => 'check', + 'name' => 'reverse', + 'default' => $this->reverse, + 'label-message' => 'pageswithprop-reverse', + 'required' => false, + ], + 'sortbyvalue' => [ + 'type' => 'check', + 'name' => 'sortbyvalue', + 'default' => $this->sortByValue, + 'label-message' => 'pageswithprop-sortbyvalue', + 'required' => false, + ] + ], $this->getContext() ); + $form->setMethod( 'get' ); + $form->setSubmitCallback( [ $this, 'onSubmit' ] ); + $form->setWrapperLegendMsg( 'pageswithprop-legend' ); + $form->addHeaderText( $this->msg( 'pageswithprop-text' )->parseAsBlock() ); + $form->setSubmitTextMsg( 'pageswithprop-submit' ); + + $form->prepareForm(); + $form->displayForm( false ); + if ( $propname !== '' && $propname !== null ) { + $form->trySubmit(); + } + } + + public function onSubmit( $data, $form ) { + $this->propName = $data['propname']; + parent::execute( $data['propname'] ); + } + + /** + * 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 + * @param int $offset Number of pages to skip + * @return string[] Matching subpages + */ + public function prefixSearchSubpages( $search, $limit, $offset ) { + $subpages = array_keys( $this->queryExistingProps( $limit, $offset ) ); + // We've already limited and offsetted, set to N and 0 respectively. + return self::prefixSearchArray( $search, count( $subpages ), $subpages, 0 ); + } + + /** + * Disable RSS/Atom feeds + * @return bool + */ + function isSyndicated() { + return false; + } + + public function getQueryInfo() { + return [ + 'tables' => [ 'page_props', 'page' ], + 'fields' => [ + 'page_id' => 'pp_page', + 'page_namespace', + 'page_title', + 'page_len', + 'page_is_redirect', + 'page_latest', + 'pp_value', + ], + 'conds' => [ + 'pp_propname' => $this->propName, + ], + 'join_conds' => [ + 'page' => [ 'INNER JOIN', 'page_id = pp_page' ] + ], + 'options' => [] + ]; + } + + function getOrderFields() { + $sort = [ 'page_id' ]; + if ( $this->sortByValue ) { + array_unshift( $sort, 'pp_sortkey' ); + } + return $sort; + } + + /** + * @return bool + */ + public function sortDescending() { + return !$this->reverse; + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + $title = Title::newFromRow( $result ); + $ret = $this->getLinkRenderer()->makeKnownLink( $title ); + if ( $result->pp_value !== '' ) { + // Do not show very long or binary values on the special page + $valueLength = strlen( $result->pp_value ); + $isBinary = strpos( $result->pp_value, "\0" ) !== false; + $isTooLong = $valueLength > 1024; + + if ( $isBinary || $isTooLong ) { + $message = $this + ->msg( $isBinary ? 'pageswithprop-prophidden-binary' : 'pageswithprop-prophidden-long' ) + ->params( $this->getLanguage()->formatSize( $valueLength ) ); + + $propValue = Html::element( 'span', [ 'class' => 'prop-value-hidden' ], $message->text() ); + } else { + $propValue = Html::element( 'span', [ 'class' => 'prop-value' ], $result->pp_value ); + } + + $ret .= $this->msg( 'colon-separator' )->escaped() . $propValue; + } + + return $ret; + } + + public function getExistingPropNames() { + if ( $this->existingPropNames === null ) { + $this->existingPropNames = $this->queryExistingProps(); + } + return $this->existingPropNames; + } + + protected function queryExistingProps( $limit = null, $offset = 0 ) { + $opts = [ + 'DISTINCT', 'ORDER BY' => 'pp_propname' + ]; + if ( $limit ) { + $opts['LIMIT'] = $limit; + } + if ( $offset ) { + $opts['OFFSET'] = $offset; + } + + $res = wfGetDB( DB_REPLICA )->select( + 'page_props', + 'pp_propname', + '', + __METHOD__, + $opts + ); + + $propnames = []; + foreach ( $res as $row ) { + $propnames[$row->pp_propname] = $row->pp_propname; + } + + return $propnames; + } + + protected function getGroupName() { + return 'pages'; + } +} diff --git a/www/wiki/includes/specials/SpecialPasswordReset.php b/www/wiki/includes/specials/SpecialPasswordReset.php new file mode 100644 index 00000000..84292f3e --- /dev/null +++ b/www/wiki/includes/specials/SpecialPasswordReset.php @@ -0,0 +1,173 @@ +<?php +/** + * Implements Special:PasswordReset + * + * 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 + */ + +use MediaWiki\Auth\AuthManager; + +/** + * Special page for requesting a password reset email. + * + * Requires the TemporaryPasswordPrimaryAuthenticationProvider and the + * EmailNotificationSecondaryAuthenticationProvider (or something providing equivalent + * functionality) to be enabled. + * + * @ingroup SpecialPage + */ +class SpecialPasswordReset extends FormSpecialPage { + /** @var PasswordReset */ + private $passwordReset = null; + + /** + * @var Status + */ + private $result; + + /** + * @var string $method Identifies which password reset field was specified by the user. + */ + private $method; + + public function __construct() { + parent::__construct( 'PasswordReset', 'editmyprivateinfo' ); + } + + private function getPasswordReset() { + if ( $this->passwordReset === null ) { + $this->passwordReset = new PasswordReset( $this->getConfig(), AuthManager::singleton() ); + } + return $this->passwordReset; + } + + public function doesWrites() { + return true; + } + + public function userCanExecute( User $user ) { + return $this->getPasswordReset()->isAllowed( $user )->isGood(); + } + + public function checkExecutePermissions( User $user ) { + $status = Status::wrap( $this->getPasswordReset()->isAllowed( $user ) ); + if ( !$status->isGood() ) { + throw new ErrorPageError( 'internalerror', $status->getMessage() ); + } + + parent::checkExecutePermissions( $user ); + } + + protected function getFormFields() { + $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' ); + $a = []; + if ( isset( $resetRoutes['username'] ) && $resetRoutes['username'] ) { + $a['Username'] = [ + 'type' => 'text', + 'label-message' => 'passwordreset-username', + ]; + + if ( $this->getUser()->isLoggedIn() ) { + $a['Username']['default'] = $this->getUser()->getName(); + } + } + + if ( isset( $resetRoutes['email'] ) && $resetRoutes['email'] ) { + $a['Email'] = [ + 'type' => 'email', + 'label-message' => 'passwordreset-email', + ]; + } + + return $a; + } + + protected function getDisplayFormat() { + return 'ooui'; + } + + public function alterForm( HTMLForm $form ) { + $resetRoutes = $this->getConfig()->get( 'PasswordResetRoutes' ); + + $form->addHiddenFields( $this->getRequest()->getValues( 'returnto', 'returntoquery' ) ); + + $i = 0; + if ( isset( $resetRoutes['username'] ) && $resetRoutes['username'] ) { + $i++; + } + if ( isset( $resetRoutes['email'] ) && $resetRoutes['email'] ) { + $i++; + } + + $message = ( $i > 1 ) ? 'passwordreset-text-many' : 'passwordreset-text-one'; + + $form->setHeaderText( $this->msg( $message, $i )->parseAsBlock() ); + $form->setSubmitTextMsg( 'mailmypassword' ); + } + + /** + * Process the form. At this point we know that the user passes all the criteria in + * userCanExecute(), and if the data array contains 'Username', etc, then Username + * resets are allowed. + * @param array $data + * @throws MWException + * @throws ThrottledError|PermissionsError + * @return Status + */ + public function onSubmit( array $data ) { + $username = isset( $data['Username'] ) ? $data['Username'] : null; + $email = isset( $data['Email'] ) ? $data['Email'] : null; + + $this->method = $username ? 'username' : 'email'; + $this->result = Status::wrap( + $this->getPasswordReset()->execute( $this->getUser(), $username, $email ) ); + + if ( $this->result->hasMessage( 'actionthrottledtext' ) ) { + throw new ThrottledError; + } + + return $this->result; + } + + public function onSuccess() { + if ( $this->method === 'email' ) { + $this->getOutput()->addWikiMsg( 'passwordreset-emailsentemail' ); + } else { + $this->getOutput()->addWikiMsg( 'passwordreset-emailsentusername' ); + } + + $this->getOutput()->returnToMain(); + } + + /** + * Hide the password reset page if resets are disabled. + * @return bool + */ + public function isListed() { + if ( $this->getPasswordReset()->isAllowed( $this->getUser() )->isGood() ) { + return parent::isListed(); + } + + return false; + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/www/wiki/includes/specials/SpecialPermanentLink.php b/www/wiki/includes/specials/SpecialPermanentLink.php new file mode 100644 index 00000000..b1772b78 --- /dev/null +++ b/www/wiki/includes/specials/SpecialPermanentLink.php @@ -0,0 +1,82 @@ +<?php +/** + * Redirect from Special:PermanentLink/### to index.php?oldid=###. + * + * 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 + */ + +/** + * Redirect from Special:PermanentLink/### to index.php?oldid=###. + * + * @ingroup SpecialPage + */ +class SpecialPermanentLink extends RedirectSpecialPage { + public function __construct() { + parent::__construct( 'PermanentLink' ); + $this->mAllowedRedirectParams = []; + } + + /** + * @param string|null $subpage + * @return Title|bool + */ + public function getRedirect( $subpage ) { + $subpage = intval( $subpage ); + if ( $subpage === 0 ) { + return false; + } + $this->mAddedRedirectParams['oldid'] = $subpage; + + return true; + } + + protected function showNoRedirectPage() { + $this->setHeaders(); + $this->outputHeader(); + $this->showForm(); + } + + private function showForm() { + $form = HTMLForm::factory( 'ooui', [ + 'revid' => [ + 'type' => 'int', + 'name' => 'revid', + 'label-message' => 'permanentlink-revid', + ], + ], $this->getContext(), 'permanentlink' ); + $form->setSubmitTextMsg( 'permanentlink-submit' ); + $form->setSubmitCallback( [ $this, 'onFormSubmit' ] ); + $form->show(); + } + + public function onFormSubmit( $formData ) { + $revid = $formData['revid']; + $title = $this->getPageTitle( $revid ?: null ); + $url = $title->getFullUrlForRedirect(); + $this->getOutput()->redirect( $url ); + } + + public function isListed() { + return true; + } + + protected function getGroupName() { + return 'redirects'; + } +} diff --git a/www/wiki/includes/specials/SpecialPreferences.php b/www/wiki/includes/specials/SpecialPreferences.php new file mode 100644 index 00000000..a5c24e7b --- /dev/null +++ b/www/wiki/includes/specials/SpecialPreferences.php @@ -0,0 +1,173 @@ +<?php +/** + * Implements Special:Preferences + * + * 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 + */ + +use MediaWiki\MediaWikiServices; + +/** + * A special page that allows users to change their preferences + * + * @ingroup SpecialPage + */ +class SpecialPreferences extends SpecialPage { + function __construct() { + parent::__construct( 'Preferences' ); + } + + public function doesWrites() { + return true; + } + + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + $out = $this->getOutput(); + $out->disallowUserJs(); # Prevent hijacked user scripts from sniffing passwords etc. + + $this->requireLogin( 'prefsnologintext2' ); + $this->checkReadOnly(); + + if ( $par == 'reset' ) { + $this->showResetForm(); + + return; + } + + $out->addModules( 'mediawiki.special.preferences' ); + $out->addModuleStyles( 'mediawiki.special.preferences.styles' ); + + $session = $this->getRequest()->getSession(); + if ( $session->get( 'specialPreferencesSaveSuccess' ) ) { + // Remove session data for the success message + $session->remove( 'specialPreferencesSaveSuccess' ); + $out->addModuleStyles( 'mediawiki.notification.convertmessagebox.styles' ); + + $out->addHTML( + Html::rawElement( + 'div', + [ + 'class' => 'mw-preferences-messagebox mw-notify-success successbox', + 'id' => 'mw-preferences-success', + 'data-mw-autohide' => 'false', + ], + Html::element( 'p', [], $this->msg( 'savedprefs' )->text() ) + ) + ); + } + + $this->addHelpLink( 'Help:Preferences' ); + + // Load the user from the master to reduce CAS errors on double post (T95839) + if ( $this->getRequest()->wasPosted() ) { + $user = $this->getUser()->getInstanceForUpdate() ?: $this->getUser(); + } else { + $user = $this->getUser(); + } + + $htmlForm = $this->getFormObject( $user, $this->getContext() ); + $sectionTitles = $htmlForm->getPreferenceSections(); + + $prefTabs = ''; + foreach ( $sectionTitles as $key ) { + $prefTabs .= Html::rawElement( 'li', + [ + 'role' => 'presentation', + 'class' => ( $key === 'personal' ) ? 'selected' : null + ], + Html::rawElement( 'a', + [ + 'id' => 'preftab-' . $key, + 'role' => 'tab', + 'href' => '#mw-prefsection-' . $key, + 'aria-controls' => 'mw-prefsection-' . $key, + 'aria-selected' => ( $key === 'personal' ) ? 'true' : 'false', + 'tabIndex' => ( $key === 'personal' ) ? 0 : -1, + ], + $htmlForm->getLegend( $key ) + ) + ); + } + + $out->addHTML( + Html::rawElement( 'ul', + [ + 'id' => 'preftoc', + 'role' => 'tablist' + ], + $prefTabs ) + ); + $htmlForm->show(); + } + + /** + * Get the preferences form to use. + * @param User $user The user. + * @param IContextSource $context The context. + * @return PreferencesForm|HTMLForm + */ + protected function getFormObject( $user, IContextSource $context ) { + $preferencesFactory = MediaWikiServices::getInstance()->getPreferencesFactory(); + $form = $preferencesFactory->getForm( $user, $context ); + return $form; + } + + protected function showResetForm() { + if ( !$this->getUser()->isAllowed( 'editmyoptions' ) ) { + throw new PermissionsError( 'editmyoptions' ); + } + + $this->getOutput()->addWikiMsg( 'prefs-reset-intro' ); + + $context = new DerivativeContext( $this->getContext() ); + $context->setTitle( $this->getPageTitle( 'reset' ) ); // Reset subpage + $htmlForm = new HTMLForm( [], $context, 'prefs-restore' ); + + $htmlForm->setSubmitTextMsg( 'restoreprefs' ); + $htmlForm->setSubmitDestructive(); + $htmlForm->setSubmitCallback( [ $this, 'submitReset' ] ); + $htmlForm->suppressReset(); + + $htmlForm->show(); + } + + public function submitReset( $formData ) { + if ( !$this->getUser()->isAllowed( 'editmyoptions' ) ) { + throw new PermissionsError( 'editmyoptions' ); + } + + $user = $this->getUser()->getInstanceForUpdate(); + $user->resetOptions( 'all', $this->getContext() ); + $user->saveSettings(); + + // Set session data for the success message + $this->getRequest()->getSession()->set( 'specialPreferencesSaveSuccess', 1 ); + + $url = $this->getPageTitle()->getFullUrlForRedirect(); + $this->getOutput()->redirect( $url ); + + return true; + } + + protected function getGroupName() { + return 'users'; + } +} diff --git a/www/wiki/includes/specials/SpecialPrefixindex.php b/www/wiki/includes/specials/SpecialPrefixindex.php new file mode 100644 index 00000000..34ffa073 --- /dev/null +++ b/www/wiki/includes/specials/SpecialPrefixindex.php @@ -0,0 +1,319 @@ +<?php +/** + * Implements Special:Prefixindex + * + * 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 + */ +use MediaWiki\MediaWikiServices; + +/** + * Implements Special:Prefixindex + * + * @ingroup SpecialPage + */ +class SpecialPrefixindex extends SpecialAllPages { + + /** + * Whether to remove the searched prefix from the displayed link. Useful + * for inclusion of a set of sub pages in a root page. + */ + protected $stripPrefix = false; + + protected $hideRedirects = false; + + // Inherit $maxPerPage + + function __construct() { + parent::__construct( 'Prefixindex' ); + } + + /** + * Entry point : initialise variables and call subfunctions. + * @param string $par Becomes "FOO" when called like Special:Prefixindex/FOO (default null) + */ + function execute( $par ) { + global $wgContLang; + + $this->setHeaders(); + $this->outputHeader(); + + $out = $this->getOutput(); + $out->addModuleStyles( 'mediawiki.special' ); + + # GET values + $request = $this->getRequest(); + $from = $request->getVal( 'from', '' ); + $prefix = $request->getVal( 'prefix', '' ); + $ns = $request->getIntOrNull( 'namespace' ); + $namespace = (int)$ns; // if no namespace given, use 0 (NS_MAIN). + $this->hideRedirects = $request->getBool( 'hideredirects', $this->hideRedirects ); + $this->stripPrefix = $request->getBool( 'stripprefix', $this->stripPrefix ); + + $namespaces = $wgContLang->getNamespaces(); + $out->setPageTitle( + ( $namespace > 0 && array_key_exists( $namespace, $namespaces ) ) + ? $this->msg( 'prefixindex-namespace', str_replace( '_', ' ', $namespaces[$namespace] ) ) + : $this->msg( 'prefixindex' ) + ); + + $showme = ''; + if ( $par !== null ) { + $showme = $par; + } elseif ( $prefix != '' ) { + $showme = $prefix; + } elseif ( $from != '' && $ns === null ) { + // For back-compat with Special:Allpages + // Don't do this if namespace is passed, so paging works when doing NS views. + $showme = $from; + } + + // T29864: if transcluded, show all pages instead of the form. + if ( $this->including() || $showme != '' || $ns !== null ) { + $this->showPrefixChunk( $namespace, $showme, $from ); + } else { + $out->addHTML( $this->namespacePrefixForm( $namespace, null ) ); + } + } + + /** + * HTML for the top form + * @param int $namespace A namespace constant (default NS_MAIN). + * @param string $from DbKey we are starting listing at. + * @return string + */ + protected function namespacePrefixForm( $namespace = NS_MAIN, $from = '' ) { + $out = Xml::openElement( 'div', [ 'class' => 'namespaceoptions' ] ); + $out .= Xml::openElement( + 'form', + [ 'method' => 'get', 'action' => $this->getConfig()->get( 'Script' ) ] + ); + $out .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ); + $out .= Xml::openElement( 'fieldset' ); + $out .= Xml::element( 'legend', null, $this->msg( 'allpages' )->text() ); + $out .= Xml::openElement( 'table', [ 'id' => 'nsselect', 'class' => 'allpages' ] ); + $out .= "<tr> + <td class='mw-label'>" . + Xml::label( $this->msg( 'allpagesprefix' )->text(), 'nsfrom' ) . + "</td> + <td class='mw-input'>" . + Xml::input( 'prefix', 30, str_replace( '_', ' ', $from ), [ 'id' => 'nsfrom' ] ) . + "</td> + </tr> + <tr> + <td class='mw-label'>" . + Xml::label( $this->msg( 'namespace' )->text(), 'namespace' ) . + "</td> + <td class='mw-input'>" . + Html::namespaceSelector( [ + 'selected' => $namespace, + ], [ + 'name' => 'namespace', + 'id' => 'namespace', + 'class' => 'namespaceselector', + ] ) . + Xml::checkLabel( + $this->msg( 'allpages-hide-redirects' )->text(), + 'hideredirects', + 'hideredirects', + $this->hideRedirects + ) . ' ' . + Xml::checkLabel( + $this->msg( 'prefixindex-strip' )->text(), + 'stripprefix', + 'stripprefix', + $this->stripPrefix + ) . ' ' . + Xml::submitButton( $this->msg( 'prefixindex-submit' )->text() ) . + "</td> + </tr>"; + $out .= Xml::closeElement( 'table' ); + $out .= Xml::closeElement( 'fieldset' ); + $out .= Xml::closeElement( 'form' ); + $out .= Xml::closeElement( 'div' ); + + return $out; + } + + /** + * @param int $namespace Default NS_MAIN + * @param string $prefix + * @param string $from List all pages from this name (default false) + */ + protected function showPrefixChunk( $namespace = NS_MAIN, $prefix, $from = null ) { + global $wgContLang; + + if ( $from === null ) { + $from = $prefix; + } + + $fromList = $this->getNamespaceKeyAndText( $namespace, $from ); + $prefixList = $this->getNamespaceKeyAndText( $namespace, $prefix ); + $namespaces = $wgContLang->getNamespaces(); + $res = null; + $n = 0; + $nextRow = null; + + if ( !$prefixList || !$fromList ) { + $out = $this->msg( 'allpagesbadtitle' )->parseAsBlock(); + } elseif ( !array_key_exists( $namespace, $namespaces ) ) { + // Show errormessage and reset to NS_MAIN + $out = $this->msg( 'allpages-bad-ns', $namespace )->parse(); + $namespace = NS_MAIN; + } else { + list( $namespace, $prefixKey, $prefix ) = $prefixList; + list( /* $fromNS */, $fromKey, ) = $fromList; + + # ## @todo FIXME: Should complain if $fromNs != $namespace + + $dbr = wfGetDB( DB_REPLICA ); + + $conds = [ + 'page_namespace' => $namespace, + 'page_title' . $dbr->buildLike( $prefixKey, $dbr->anyString() ), + 'page_title >= ' . $dbr->addQuotes( $fromKey ), + ]; + + if ( $this->hideRedirects ) { + $conds['page_is_redirect'] = 0; + } + + $res = $dbr->select( 'page', + array_merge( + [ 'page_namespace', 'page_title' ], + LinkCache::getSelectFields() + ), + $conds, + __METHOD__, + [ + 'ORDER BY' => 'page_title', + 'LIMIT' => $this->maxPerPage + 1, + 'USE INDEX' => 'name_title', + ] + ); + + // @todo FIXME: Side link to previous + + if ( $res->numRows() > 0 ) { + $out = Html::openElement( 'ul', [ 'class' => 'mw-prefixindex-list' ] ); + $linkCache = MediaWikiServices::getInstance()->getLinkCache(); + + $prefixLength = strlen( $prefix ); + foreach ( $res as $row ) { + if ( $n >= $this->maxPerPage ) { + $nextRow = $row; + break; + } + $title = Title::newFromRow( $row ); + // Make sure it gets into LinkCache + $linkCache->addGoodLinkObjFromRow( $title, $row ); + $displayed = $title->getText(); + // Try not to generate unclickable links + if ( $this->stripPrefix && $prefixLength !== strlen( $displayed ) ) { + $displayed = substr( $displayed, $prefixLength ); + } + $link = ( $title->isRedirect() ? '<div class="allpagesredirect">' : '' ) . + $this->getLinkRenderer()->makeKnownLink( + $title, + $displayed + ) . + ( $title->isRedirect() ? '</div>' : '' ); + + $out .= "<li>$link</li>\n"; + $n++; + + } + $out .= Html::closeElement( 'ul' ); + + if ( $res->numRows() > 2 ) { + // Only apply CSS column styles if there's more than 2 entries. + // Otherwise rendering is broken as "mw-prefixindex-body"'s CSS column count is 3. + $out = Html::rawElement( 'div', [ 'class' => 'mw-prefixindex-body' ], $out ); + } + } else { + $out = ''; + } + } + + $output = $this->getOutput(); + + if ( $this->including() ) { + // We don't show the nav-links and the form when included into other + // pages so let's just finish here. + $output->addHTML( $out ); + return; + } + + $topOut = $this->namespacePrefixForm( $namespace, $prefix ); + + if ( $res && ( $n == $this->maxPerPage ) && $nextRow ) { + $query = [ + 'from' => $nextRow->page_title, + 'prefix' => $prefix, + 'hideredirects' => $this->hideRedirects, + 'stripprefix' => $this->stripPrefix, + ]; + + if ( $namespace || $prefix == '' ) { + // Keep the namespace even if it's 0 for empty prefixes. + // This tells us we're not just a holdover from old links. + $query['namespace'] = $namespace; + } + + $nextLink = $this->getLinkRenderer()->makeKnownLink( + $this->getPageTitle(), + $this->msg( 'nextpage', str_replace( '_', ' ', $nextRow->page_title ) )->text(), + [], + $query + ); + + // Link shown at the top of the page below the form + $topOut .= Html::rawElement( 'div', + [ 'class' => 'mw-prefixindex-nav' ], + $nextLink + ); + + // Link shown at the footer + $out .= "\n" . Html::element( 'hr' ) . + Html::rawElement( + 'div', + [ 'class' => 'mw-prefixindex-nav' ], + $nextLink + ); + + } + + $output->addHTML( $topOut . $out ); + } + + /** + * 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 'pages'; + } +} diff --git a/www/wiki/includes/specials/SpecialProtectedpages.php b/www/wiki/includes/specials/SpecialProtectedpages.php new file mode 100644 index 00000000..d693b990 --- /dev/null +++ b/www/wiki/includes/specials/SpecialProtectedpages.php @@ -0,0 +1,207 @@ +<?php +/** + * Implements Special:Protectedpages + * + * 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 + */ + +/** + * A special page that lists protected pages + * + * @ingroup SpecialPage + */ +class SpecialProtectedpages extends SpecialPage { + protected $IdLevel = 'level'; + protected $IdType = 'type'; + + public function __construct() { + parent::__construct( 'Protectedpages' ); + } + + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + $this->getOutput()->addModuleStyles( 'mediawiki.special' ); + + $request = $this->getRequest(); + $type = $request->getVal( $this->IdType ); + $level = $request->getVal( $this->IdLevel ); + $sizetype = $request->getVal( 'size-mode' ); + $size = $request->getIntOrNull( 'size' ); + $ns = $request->getIntOrNull( 'namespace' ); + $indefOnly = $request->getBool( 'indefonly' ) ? 1 : 0; + $cascadeOnly = $request->getBool( 'cascadeonly' ) ? 1 : 0; + $noRedirect = $request->getBool( 'noredirect' ) ? 1 : 0; + + $pager = new ProtectedPagesPager( + $this, + [], + $type, + $level, + $ns, + $sizetype, + $size, + $indefOnly, + $cascadeOnly, + $noRedirect, + $this->getLinkRenderer() + ); + + $this->getOutput()->addHTML( $this->showOptions( + $ns, + $type, + $level, + $sizetype, + $size, + $indefOnly, + $cascadeOnly, + $noRedirect + ) ); + + if ( $pager->getNumRows() ) { + $this->getOutput()->addParserOutputContent( $pager->getFullOutput() ); + } else { + $this->getOutput()->addWikiMsg( 'protectedpagesempty' ); + } + } + + /** + * @param int $namespace + * @param string $type Restriction type + * @param string $level Restriction level + * @param string $sizetype "min" or "max" + * @param int $size + * @param bool $indefOnly Only indefinite protection + * @param bool $cascadeOnly Only cascading protection + * @param bool $noRedirect Don't show redirects + * @return string Input form + */ + protected function showOptions( $namespace, $type = 'edit', $level, $sizetype, + $size, $indefOnly, $cascadeOnly, $noRedirect + ) { + $formDescriptor = [ + 'namespace' => [ + 'class' => HTMLSelectNamespace::class, + 'name' => 'namespace', + 'id' => 'namespace', + 'cssclass' => 'namespaceselector', + 'all' => '', + 'label' => $this->msg( 'namespace' )->text(), + ], + 'typemenu' => $this->getTypeMenu( $type ), + 'levelmenu' => $this->getLevelMenu( $level ), + 'expirycheck' => [ + 'type' => 'check', + 'label' => $this->msg( 'protectedpages-indef' )->text(), + 'name' => 'indefonly', + 'id' => 'indefonly', + ], + 'cascadecheck' => [ + 'type' => 'check', + 'label' => $this->msg( 'protectedpages-cascade' )->text(), + 'name' => 'cascadeonly', + 'id' => 'cascadeonly', + ], + 'redirectcheck' => [ + 'type' => 'check', + 'label' => $this->msg( 'protectedpages-noredirect' )->text(), + 'name' => 'noredirect', + 'id' => 'noredirect', + ], + 'sizelimit' => [ + 'class' => HTMLSizeFilterField::class, + 'name' => 'size', + ] + ]; + $htmlForm = new HTMLForm( $formDescriptor, $this->getContext() ); + $htmlForm + ->setMethod( 'get' ) + ->setWrapperLegendMsg( 'protectedpages' ) + ->setSubmitText( $this->msg( 'protectedpages-submit' )->text() ); + + return $htmlForm->prepareForm()->getHTML( false ); + } + + /** + * Creates the input label of the restriction type + * @param string $pr_type Protection type + * @return array + */ + protected function getTypeMenu( $pr_type ) { + $m = []; // Temporary array + $options = []; + + // First pass to load the log names + foreach ( Title::getFilteredRestrictionTypes( true ) as $type ) { + // Messages: restriction-edit, restriction-move, restriction-create, restriction-upload + $text = $this->msg( "restriction-$type" )->text(); + $m[$text] = $type; + } + + // Third pass generates sorted XHTML content + foreach ( $m as $text => $type ) { + $options[$text] = $type; + } + + return [ + 'type' => 'select', + 'options' => $options, + 'label' => $this->msg( 'restriction-type' )->text(), + 'name' => $this->IdType, + 'id' => $this->IdType, + ]; + } + + /** + * Creates the input label of the restriction level + * @param string $pr_level Protection level + * @return array + */ + protected function getLevelMenu( $pr_level ) { + // Temporary array + $m = [ $this->msg( 'restriction-level-all' )->text() => 0 ]; + $options = []; + + // First pass to load the log names + foreach ( $this->getConfig()->get( 'RestrictionLevels' ) as $type ) { + // Messages used can be 'restriction-level-sysop' and 'restriction-level-autoconfirmed' + if ( $type != '' && $type != '*' ) { + $text = $this->msg( "restriction-level-$type" )->text(); + $m[$text] = $type; + } + } + + // Third pass generates sorted XHTML content + foreach ( $m as $text => $type ) { + $options[$text] = $type; + } + + return [ + 'type' => 'select', + 'options' => $options, + 'label' => $this->msg( 'restriction-level' )->text(), + 'name' => $this->IdLevel, + 'id' => $this->IdLevel + ]; + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialProtectedtitles.php b/www/wiki/includes/specials/SpecialProtectedtitles.php new file mode 100644 index 00000000..fa12f507 --- /dev/null +++ b/www/wiki/includes/specials/SpecialProtectedtitles.php @@ -0,0 +1,177 @@ +<?php +/** + * Implements Special:Protectedtitles + * + * 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 + */ + +/** + * A special page that list protected titles from creation + * + * @ingroup SpecialPage + */ +class SpecialProtectedtitles extends SpecialPage { + protected $IdLevel = 'level'; + protected $IdType = 'type'; + + public function __construct() { + parent::__construct( 'Protectedtitles' ); + } + + function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + + $request = $this->getRequest(); + $type = $request->getVal( $this->IdType ); + $level = $request->getVal( $this->IdLevel ); + $sizetype = $request->getVal( 'sizetype' ); + $size = $request->getIntOrNull( 'size' ); + $NS = $request->getIntOrNull( 'namespace' ); + + $pager = new ProtectedTitlesPager( $this, [], $type, $level, $NS, $sizetype, $size ); + + $this->getOutput()->addHTML( $this->showOptions( $NS, $type, $level ) ); + + if ( $pager->getNumRows() ) { + $this->getOutput()->addHTML( + $pager->getNavigationBar() . + '<ul>' . $pager->getBody() . '</ul>' . + $pager->getNavigationBar() + ); + } else { + $this->getOutput()->addWikiMsg( 'protectedtitlesempty' ); + } + } + + /** + * Callback function to output a restriction + * + * @param object $row Database row + * @return string + */ + function formatRow( $row ) { + $title = Title::makeTitleSafe( $row->pt_namespace, $row->pt_title ); + if ( !$title ) { + return Html::rawElement( + 'li', + [], + Html::element( + 'span', + [ 'class' => 'mw-invalidtitle' ], + Linker::getInvalidTitleDescription( + $this->getContext(), + $row->pt_namespace, + $row->pt_title + ) + ) + ) . "\n"; + } + + $link = $this->getLinkRenderer()->makeLink( $title ); + // Messages: restriction-level-sysop, restriction-level-autoconfirmed + $description = $this->msg( 'restriction-level-' . $row->pt_create_perm )->escaped(); + $lang = $this->getLanguage(); + $expiry = strlen( $row->pt_expiry ) ? + $lang->formatExpiry( $row->pt_expiry, TS_MW ) : + 'infinity'; + + if ( $expiry !== 'infinity' ) { + $user = $this->getUser(); + $description .= $this->msg( 'comma-separator' )->escaped() . $this->msg( + 'protect-expiring-local', + $lang->userTimeAndDate( $expiry, $user ), + $lang->userDate( $expiry, $user ), + $lang->userTime( $expiry, $user ) + )->escaped(); + } + + return '<li>' . $lang->specialList( $link, $description ) . "</li>\n"; + } + + /** + * @param int $namespace + * @param string $type + * @param string $level + * @return string + * @private + */ + function showOptions( $namespace, $type = 'edit', $level ) { + $formDescriptor = [ + 'namespace' => [ + 'class' => 'HTMLSelectNamespace', + 'name' => 'namespace', + 'id' => 'namespace', + 'cssclass' => 'namespaceselector', + 'all' => '', + 'label' => $this->msg( 'namespace' )->text() + ], + 'levelmenu' => $this->getLevelMenu( $level ) + ]; + + $htmlForm = new HTMLForm( $formDescriptor, $this->getContext() ); + $htmlForm + ->setMethod( 'get' ) + ->setWrapperLegendMsg( 'protectedtitles' ) + ->setSubmitText( $this->msg( 'protectedtitles-submit' )->text() ); + + return $htmlForm->prepareForm()->getHTML( false ); + } + + /** + * @param string $pr_level Determines which option is selected as default + * @return string Formatted HTML + * @private + */ + function getLevelMenu( $pr_level ) { + // Temporary array + $m = [ $this->msg( 'restriction-level-all' )->text() => 0 ]; + $options = []; + + // First pass to load the log names + foreach ( $this->getConfig()->get( 'RestrictionLevels' ) as $type ) { + if ( $type != '' && $type != '*' ) { + // Messages: restriction-level-sysop, restriction-level-autoconfirmed + $text = $this->msg( "restriction-level-$type" )->text(); + $m[$text] = $type; + } + } + + // Is there only one level (aside from "all")? + if ( count( $m ) <= 2 ) { + return ''; + } + // Third pass generates sorted XHTML content + foreach ( $m as $text => $type ) { + $options[ $text ] = $type; + } + + return [ + 'type' => 'select', + 'options' => $options, + 'label' => $this->msg( 'restriction-level' )->text(), + 'name' => $this->IdLevel, + 'id' => $this->IdLevel + ]; + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialRandomInCategory.php b/www/wiki/includes/specials/SpecialRandomInCategory.php new file mode 100644 index 00000000..adf12d40 --- /dev/null +++ b/www/wiki/includes/specials/SpecialRandomInCategory.php @@ -0,0 +1,315 @@ +<?php +/** + * Implements Special:RandomInCategory + * + * 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 + * @author Brian Wolff + */ + +/** + * Special page to direct the user to a random page + * + * @note The method used here is rather biased. It is assumed that + * the use of this page will be people wanting to get a random page + * out of a maintenance category, to fix it up. The method used by + * this page should return different pages in an unpredictable fashion + * which is hoped to be sufficient, even if some pages are selected + * more often than others. + * + * A more unbiased method could be achieved by adding a cl_random field + * to the categorylinks table. + * + * The method used here is as follows: + * * Find the smallest and largest timestamp in the category + * * Pick a random timestamp in between + * * Pick an offset between 0 and 30 + * * Get the offset'ed page that is newer than the timestamp selected + * The offset is meant to counter the fact the timestamps aren't usually + * uniformly distributed, so if things are very non-uniform at least we + * won't have the same page selected 99% of the time. + * + * @ingroup SpecialPage + */ +class SpecialRandomInCategory extends FormSpecialPage { + /** @var string[] */ + protected $extra = []; // Extra SQL statements + /** @var Title|false */ + protected $category = false; // Title object of category + /** @var int */ + protected $maxOffset = 30; // Max amount to fudge randomness by. + /** @var int|null */ + private $maxTimestamp = null; + /** @var int|null */ + private $minTimestamp = null; + + public function __construct( $name = 'RandomInCategory' ) { + parent::__construct( $name ); + } + + /** + * Set which category to use. + * @param Title $cat + */ + public function setCategory( Title $cat ) { + $this->category = $cat; + $this->maxTimestamp = null; + $this->minTimestamp = null; + } + + protected function getFormFields() { + $this->addHelpLink( 'Help:RandomInCategory' ); + + return [ + 'category' => [ + 'type' => 'title', + 'namespace' => NS_CATEGORY, + 'relative' => true, + 'label-message' => 'randomincategory-category', + 'required' => true, + ] + ]; + } + + public function requiresWrite() { + return false; + } + + public function requiresUnblock() { + return false; + } + + protected function getDisplayFormat() { + return 'ooui'; + } + + protected function alterForm( HTMLForm $form ) { + $form->setSubmitTextMsg( 'randomincategory-submit' ); + } + + protected function setParameter( $par ) { + // if subpage present, fake form submission + $this->onSubmit( [ 'category' => $par ] ); + } + + public function onSubmit( array $data ) { + $cat = false; + + $categoryStr = $data['category']; + + if ( $categoryStr ) { + $cat = Title::newFromText( $categoryStr, NS_CATEGORY ); + } + + if ( $cat && $cat->getNamespace() !== NS_CATEGORY ) { + // Someone searching for something like "Wikipedia:Foo" + $cat = Title::makeTitleSafe( NS_CATEGORY, $categoryStr ); + } + + if ( $cat ) { + $this->setCategory( $cat ); + } + + if ( !$this->category && $categoryStr ) { + $msg = $this->msg( 'randomincategory-invalidcategory', + wfEscapeWikiText( $categoryStr ) ); + + return Status::newFatal( $msg ); + + } elseif ( !$this->category ) { + return false; // no data sent + } + + $title = $this->getRandomTitle(); + + if ( is_null( $title ) ) { + $msg = $this->msg( 'randomincategory-nopages', + $this->category->getText() ); + + return Status::newFatal( $msg ); + } + + $this->getOutput()->redirect( $title->getFullURL() ); + } + + /** + * Choose a random title. + * @return Title|null Title object (or null if nothing to choose from) + */ + public function getRandomTitle() { + // Convert to float, since we do math with the random number. + $rand = (float)wfRandom(); + $title = null; + + // Given that timestamps are rather unevenly distributed, we also + // use an offset between 0 and 30 to make any biases less noticeable. + $offset = mt_rand( 0, $this->maxOffset ); + + if ( mt_rand( 0, 1 ) ) { + $up = true; + } else { + $up = false; + } + + $row = $this->selectRandomPageFromDB( $rand, $offset, $up ); + + // Try again without the timestamp offset (wrap around the end) + if ( !$row ) { + $row = $this->selectRandomPageFromDB( false, $offset, $up ); + } + + // Maybe the category is really small and offset too high + if ( !$row ) { + $row = $this->selectRandomPageFromDB( $rand, 0, $up ); + } + + // Just get the first entry. + if ( !$row ) { + $row = $this->selectRandomPageFromDB( false, 0, true ); + } + + if ( $row ) { + return Title::makeTitle( $row->page_namespace, $row->page_title ); + } + + return null; + } + + /** + * @param float $rand Random number between 0 and 1 + * @param int $offset Extra offset to fudge randomness + * @param bool $up True to get the result above the random number, false for below + * @return array Query information. + * @throws MWException + * @note The $up parameter is supposed to counteract what would happen if there + * was a large gap in the distribution of cl_timestamp values. This way instead + * of things to the right of the gap being favoured, both sides of the gap + * are favoured. + */ + protected function getQueryInfo( $rand, $offset, $up ) { + $op = $up ? '>=' : '<='; + $dir = $up ? 'ASC' : 'DESC'; + if ( !$this->category instanceof Title ) { + throw new MWException( 'No category set' ); + } + $qi = [ + 'tables' => [ 'categorylinks', 'page' ], + 'fields' => [ 'page_title', 'page_namespace' ], + 'conds' => array_merge( [ + 'cl_to' => $this->category->getDBkey(), + ], $this->extra ), + 'options' => [ + 'ORDER BY' => 'cl_timestamp ' . $dir, + 'LIMIT' => 1, + 'OFFSET' => $offset + ], + 'join_conds' => [ + 'page' => [ 'INNER JOIN', 'cl_from = page_id' ] + ] + ]; + + $dbr = wfGetDB( DB_REPLICA ); + $minClTime = $this->getTimestampOffset( $rand ); + if ( $minClTime ) { + $qi['conds'][] = 'cl_timestamp ' . $op . ' ' . + $dbr->addQuotes( $dbr->timestamp( $minClTime ) ); + } + + return $qi; + } + + /** + * @param float $rand Random number between 0 and 1 + * + * @return int|bool A random (unix) timestamp from the range of the category or false on failure + */ + protected function getTimestampOffset( $rand ) { + if ( $rand === false ) { + return false; + } + if ( !$this->minTimestamp || !$this->maxTimestamp ) { + try { + list( $this->minTimestamp, $this->maxTimestamp ) = $this->getMinAndMaxForCat( $this->category ); + } catch ( Exception $e ) { + // Possibly no entries in category. + return false; + } + } + + $ts = ( $this->maxTimestamp - $this->minTimestamp ) * $rand + $this->minTimestamp; + + return intval( $ts ); + } + + /** + * Get the lowest and highest timestamp for a category. + * + * @param Title $category + * @return array The lowest and highest timestamp + * @throws MWException If category has no entries. + */ + protected function getMinAndMaxForCat( Title $category ) { + $dbr = wfGetDB( DB_REPLICA ); + $res = $dbr->selectRow( + 'categorylinks', + [ + 'low' => 'MIN( cl_timestamp )', + 'high' => 'MAX( cl_timestamp )' + ], + [ + 'cl_to' => $this->category->getDBkey(), + ], + __METHOD__, + [ + 'LIMIT' => 1 + ] + ); + if ( !$res ) { + throw new MWException( 'No entries in category' ); + } + + return [ wfTimestamp( TS_UNIX, $res->low ), wfTimestamp( TS_UNIX, $res->high ) ]; + } + + /** + * @param float $rand A random number that is converted to a random timestamp + * @param int $offset A small offset to make the result seem more "random" + * @param bool $up Get the result above the random value + * @param string $fname The name of the calling method + * @return array Info for the title selected. + */ + private function selectRandomPageFromDB( $rand, $offset, $up, $fname = __METHOD__ ) { + $dbr = wfGetDB( DB_REPLICA ); + + $query = $this->getQueryInfo( $rand, $offset, $up ); + $res = $dbr->select( + $query['tables'], + $query['fields'], + $query['conds'], + $fname, + $query['options'], + $query['join_conds'] + ); + + return $res->fetchObject(); + } + + protected function getGroupName() { + return 'redirects'; + } +} diff --git a/www/wiki/includes/specials/SpecialRandompage.php b/www/wiki/includes/specials/SpecialRandompage.php new file mode 100644 index 00000000..e3b567d7 --- /dev/null +++ b/www/wiki/includes/specials/SpecialRandompage.php @@ -0,0 +1,180 @@ +<?php +/** + * Implements Special:Randompage + * + * 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 + * @author Rob Church <robchur@gmail.com>, Ilmari Karonen + */ + +/** + * Special page to direct the user to a random page + * + * @ingroup SpecialPage + */ +class RandomPage extends SpecialPage { + private $namespaces; // namespaces to select pages from + protected $isRedir = false; // should the result be a redirect? + protected $extra = []; // Extra SQL statements + + public function __construct( $name = 'Randompage' ) { + $this->namespaces = MWNamespace::getContentNamespaces(); + parent::__construct( $name ); + } + + public function getNamespaces() { + return $this->namespaces; + } + + public function setNamespace( $ns ) { + if ( !$ns || $ns < NS_MAIN ) { + $ns = NS_MAIN; + } + $this->namespaces = [ $ns ]; + } + + // select redirects instead of normal pages? + public function isRedirect() { + return $this->isRedir; + } + + public function execute( $par ) { + global $wgContLang; + + if ( is_string( $par ) ) { + // Testing for stringiness since we want to catch + // the empty string to mean main namespace only. + $this->setNamespace( $wgContLang->getNsIndex( $par ) ); + } + + $title = $this->getRandomTitle(); + + if ( is_null( $title ) ) { + $this->setHeaders(); + // Message: randompage-nopages, randomredirect-nopages + $this->getOutput()->addWikiMsg( strtolower( $this->getName() ) . '-nopages', + $this->getNsList(), count( $this->namespaces ) ); + + return; + } + + $redirectParam = $this->isRedirect() ? [ 'redirect' => 'no' ] : []; + $query = array_merge( $this->getRequest()->getValues(), $redirectParam ); + unset( $query['title'] ); + $this->getOutput()->redirect( $title->getFullURL( $query ) ); + } + + /** + * Get a comma-delimited list of namespaces we don't have + * any pages in + * @return string + */ + private function getNsList() { + global $wgContLang; + $nsNames = []; + foreach ( $this->namespaces as $n ) { + if ( $n === NS_MAIN ) { + $nsNames[] = $this->msg( 'blanknamespace' )->plain(); + } else { + $nsNames[] = $wgContLang->getNsText( $n ); + } + } + + return $wgContLang->commaList( $nsNames ); + } + + /** + * Choose a random title. + * @return Title|null Title object (or null if nothing to choose from) + */ + public function getRandomTitle() { + $randstr = wfRandom(); + $title = null; + + if ( !Hooks::run( + 'SpecialRandomGetRandomTitle', + [ &$randstr, &$this->isRedir, &$this->namespaces, &$this->extra, &$title ] + ) ) { + return $title; + } + + $row = $this->selectRandomPageFromDB( $randstr ); + + /* If we picked a value that was higher than any in + * the DB, wrap around and select the page with the + * lowest value instead! One might think this would + * skew the distribution, but in fact it won't cause + * any more bias than what the page_random scheme + * causes anyway. Trust me, I'm a mathematician. :) + */ + if ( !$row ) { + $row = $this->selectRandomPageFromDB( "0" ); + } + + if ( $row ) { + return Title::makeTitleSafe( $row->page_namespace, $row->page_title ); + } + + return null; + } + + protected function getQueryInfo( $randstr ) { + $redirect = $this->isRedirect() ? 1 : 0; + $tables = [ 'page' ]; + $conds = array_merge( [ + 'page_namespace' => $this->namespaces, + 'page_is_redirect' => $redirect, + 'page_random >= ' . $randstr + ], $this->extra ); + $joinConds = []; + + // Allow extensions to modify the query + Hooks::run( 'RandomPageQuery', [ &$tables, &$conds, &$joinConds ] ); + + return [ + 'tables' => $tables, + 'fields' => [ 'page_title', 'page_namespace' ], + 'conds' => $conds, + 'options' => [ + 'ORDER BY' => 'page_random', + 'LIMIT' => 1, + ], + 'join_conds' => $joinConds + ]; + } + + private function selectRandomPageFromDB( $randstr, $fname = __METHOD__ ) { + $dbr = wfGetDB( DB_REPLICA ); + + $query = $this->getQueryInfo( $randstr ); + $res = $dbr->select( + $query['tables'], + $query['fields'], + $query['conds'], + $fname, + $query['options'], + $query['join_conds'] + ); + + return $dbr->fetchObject( $res ); + } + + protected function getGroupName() { + return 'redirects'; + } +} diff --git a/www/wiki/includes/specials/SpecialRandomredirect.php b/www/wiki/includes/specials/SpecialRandomredirect.php new file mode 100644 index 00000000..7c36a28a --- /dev/null +++ b/www/wiki/includes/specials/SpecialRandomredirect.php @@ -0,0 +1,35 @@ +<?php +/** + * Implements Special:Randomredirect + * + * 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 + * @author Rob Church <robchur@gmail.com>, Ilmari Karonen + */ + +/** + * Special page to direct the user to a random redirect page (minus the second redirect) + * + * @ingroup SpecialPage + */ +class SpecialRandomredirect extends RandomPage { + function __construct() { + parent::__construct( 'Randomredirect' ); + $this->isRedir = true; + } +} diff --git a/www/wiki/includes/specials/SpecialRandomrootpage.php b/www/wiki/includes/specials/SpecialRandomrootpage.php new file mode 100644 index 00000000..0df8423f --- /dev/null +++ b/www/wiki/includes/specials/SpecialRandomrootpage.php @@ -0,0 +1,39 @@ +<?php + +/** + * Implements Special:Randomrootpage + * + * Copyright © 2008 Hojjat (aka Huji) + * + * 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 + */ + +class SpecialRandomrootpage extends RandomPage { + + public function __construct() { + parent::__construct( 'Randomrootpage' ); + $dbr = wfGetDB( DB_REPLICA ); + $this->extra[] = 'page_title NOT ' . $dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() ); + } + + // Don't select redirects + public function isRedirect() { + return false; + } +} diff --git a/www/wiki/includes/specials/SpecialRecentchanges.php b/www/wiki/includes/specials/SpecialRecentchanges.php new file mode 100644 index 00000000..bfef5e03 --- /dev/null +++ b/www/wiki/includes/specials/SpecialRecentchanges.php @@ -0,0 +1,956 @@ +<?php +/** + * Implements Special:Recentchanges + * + * 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 + */ + +use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\FakeResultWrapper; + +/** + * A special page that lists last changes made to the wiki + * + * @ingroup SpecialPage + */ +class SpecialRecentChanges extends ChangesListSpecialPage { + + protected static $savedQueriesPreferenceName = 'rcfilters-saved-queries'; + protected static $daysPreferenceName = 'rcdays'; // Use general RecentChanges preference + protected static $limitPreferenceName = 'rcfilters-limit'; // Use RCFilters-specific preference + + private $watchlistFilterGroupDefinition; + + public function __construct( $name = 'Recentchanges', $restriction = '' ) { + parent::__construct( $name, $restriction ); + + $this->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; + } + } + ]; + } + + /** + * Main execution point + * + * @param string $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' ) ); + } + } + + /** + * Get all custom filters + * + * @return array Map of filter URL param names to properties (msg/default) + */ + protected function getCustomFilters() { + if ( $this->customFilters === null ) { + $this->customFilters = parent::getCustomFilters(); + Hooks::run( 'SpecialRecentChangesFilters', [ $this, &$this->customFilters ], '1.23' ); + } + + return $this->customFilters; + } + + /** + * 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 runMainQueryHook( &$tables, &$fields, &$conds, + &$query_options, &$join_conds, $opts + ) { + return parent::runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, $opts ) + && Hooks::run( + 'SpecialRecentChangesQuery', + [ &$conds, &$tables, &$join_conds, $opts, &$query_options, &$fields ], + '1.23' + ); + } + + 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[] = '<hr />'; + + $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 ) { + global $wgContLang; + + $message = $this->msg( 'recentchangestext' )->inContentLanguage(); + if ( !$message->isDisabled() ) { + // 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, + $wgContLang + ); + $content = $parserOutput->getText( [ + 'enableSectionEditLinks' => false, + ] ); + // Add only metadata here (including the language links), text is added below + $this->getOutput()->addParserOutputMetadata( $parserOutput ); + + $langAttributes = [ + 'lang' => $wgContLang->getHtmlCode(), + 'dir' => $wgContLang->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' => '' ], + [ 'name' => 'namespace', 'id' => 'namespace' ] + ); + $nsLabel = Xml::label( $this->msg( 'namespace' )->text(), 'namespace' ); + $invert = Xml::checkLabel( + $this->msg( 'invert' )->text(), 'invert', 'nsinvert', + $opts['invert'], + [ 'title' => $this->msg( 'tooltip-invert' )->text() ] + ); + $associated = 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 ( !count( $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 ( !count( $articles ) || !count( $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( '<strong>' . htmlspecialchars( $title ) . '</strong>' ); + } + + 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 .= '<div class="mw-rclegend">' . $msg->parse() . "</div>\n"; + } + + $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() + ) . + '<br />'; + } + + # 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 = '<span class="rcshowhide">' . $lang->pipeList( $links ) . '</span>'; + + $rclinks = '<span class="rclinks">' . $this->msg( 'rclinks' )->rawParams( $cl, $dl, '' ) + ->parse() . '</span>'; + + $rclistfrom = '<span class="rclistfrom">' . $this->makeOptionsLink( + $this->msg( 'rclistfrom' )->rawParams( $now, $timenow, $datenow )->parse(), + [ 'from' => $timestamp ], + $nondefaults + ) . '</span>'; + + return "{$note}$rclinks<br />$pipedLinks<br />$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/www/wiki/includes/specials/SpecialRecentchangeslinked.php b/www/wiki/includes/specials/SpecialRecentchangeslinked.php new file mode 100644 index 00000000..181b4db4 --- /dev/null +++ b/www/wiki/includes/specials/SpecialRecentchangeslinked.php @@ -0,0 +1,314 @@ +<?php +/** + * Implements Special:Recentchangeslinked + * + * 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 + */ + +/** + * This is to display changes made to all articles linked in an article. + * + * @ingroup SpecialPage + */ +class SpecialRecentChangesLinked extends SpecialRecentChanges { + /** @var bool|Title */ + protected $rclTargetTitle; + + function __construct() { + parent::__construct( 'Recentchangeslinked' ); + } + + public function getDefaultOptions() { + $opts = parent::getDefaultOptions(); + $opts->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 => [ 'INNER 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, false ) . ' 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( + '<div class="mw-changeslist-empty mw-changeslist-notargetpage">' . + $this->msg( 'recentchanges-notargetpage' )->parse() . + '</div>' + ); + } elseif ( !$targetTitle || $targetTitle->isExternal() ) { + $this->getOutput()->addHTML( + '<div class="mw-changeslist-empty mw-changeslist-invalidtargetpage">' . + $this->msg( 'allpagesbadtitle' )->parse() . + '</div>' + ); + } else { + parent::outputNoResults(); + } + } +} diff --git a/www/wiki/includes/specials/SpecialRedirect.php b/www/wiki/includes/specials/SpecialRedirect.php new file mode 100644 index 00000000..e8279113 --- /dev/null +++ b/www/wiki/includes/specials/SpecialRedirect.php @@ -0,0 +1,326 @@ +<?php +/** + * Implements Special:Redirect + * + * 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 + */ + +/** + * A special page that redirects to: the user for a numeric user id, + * the file for a given filename, or the page for a given revision id. + * + * @ingroup SpecialPage + * @since 1.22 + */ +class SpecialRedirect extends FormSpecialPage { + + /** + * The type of the redirect (user/file/revision) + * + * Example value: `'user'` + * + * @var string $mType + */ + protected $mType; + + /** + * The identifier/value for the redirect (which id, which file) + * + * Example value: `'42'` + * + * @var string $mValue + */ + protected $mValue; + + function __construct() { + parent::__construct( 'Redirect' ); + $this->mType = null; + $this->mValue = null; + } + + /** + * Set $mType and $mValue based on parsed value of $subpage. + * @param string $subpage + */ + function setParameter( $subpage ) { + // parse $subpage to pull out the parts + $parts = explode( '/', $subpage, 2 ); + $this->mType = count( $parts ) > 0 ? $parts[0] : null; + $this->mValue = count( $parts ) > 1 ? $parts[1] : null; + } + + /** + * Handle Special:Redirect/user/xxxx (by redirecting to User:YYYY) + * + * @return string|null Url to redirect to, or null if $mValue is invalid. + */ + function dispatchUser() { + if ( !ctype_digit( $this->mValue ) ) { + return null; + } + $user = User::newFromId( (int)$this->mValue ); + $username = $user->getName(); // load User as side-effect + if ( $user->isAnon() ) { + return null; + } + $userpage = Title::makeTitle( NS_USER, $username ); + + return $userpage->getFullURL( '', false, PROTO_CURRENT ); + } + + /** + * Handle Special:Redirect/file/xxxx + * + * @return string|null Url to redirect to, or null if $mValue is not found. + */ + function dispatchFile() { + $title = Title::makeTitleSafe( NS_FILE, $this->mValue ); + + if ( !$title instanceof Title ) { + return null; + } + $file = wfFindFile( $title ); + + if ( !$file || !$file->exists() ) { + return null; + } + // Default behavior: Use the direct link to the file. + $url = $file->getUrl(); + $request = $this->getRequest(); + $width = $request->getInt( 'width', -1 ); + $height = $request->getInt( 'height', -1 ); + + // If a width is requested... + if ( $width != -1 ) { + $mto = $file->transform( [ 'width' => $width, 'height' => $height ] ); + // ... and we can + if ( $mto && !$mto->isError() ) { + // ... change the URL to point to a thumbnail. + $url = $mto->getUrl(); + } + } + + return $url; + } + + /** + * Handle Special:Redirect/revision/xxx + * (by redirecting to index.php?oldid=xxx) + * + * @return string|null Url to redirect to, or null if $mValue is invalid. + */ + function dispatchRevision() { + $oldid = $this->mValue; + if ( !ctype_digit( $oldid ) ) { + return null; + } + $oldid = (int)$oldid; + if ( $oldid === 0 ) { + return null; + } + + return wfAppendQuery( wfScript( 'index' ), [ + 'oldid' => $oldid + ] ); + } + + /** + * Handle Special:Redirect/page/xxx (by redirecting to index.php?curid=xxx) + * + * @return string|null Url to redirect to, or null if $mValue is invalid. + */ + function dispatchPage() { + $curid = $this->mValue; + if ( !ctype_digit( $curid ) ) { + return null; + } + $curid = (int)$curid; + if ( $curid === 0 ) { + return null; + } + + return wfAppendQuery( wfScript( 'index' ), [ + 'curid' => $curid + ] ); + } + + /** + * Handle Special:Redirect/logid/xxx + * (by redirecting to index.php?title=Special:Log&logid=xxx) + * + * @since 1.27 + * @return string|null Url to redirect to, or null if $mValue is invalid. + */ + function dispatchLog() { + $logid = $this->mValue; + if ( !ctype_digit( $logid ) ) { + return null; + } + $logid = (int)$logid; + if ( $logid === 0 ) { + return null; + } + $query = [ 'title' => 'Special:Log', 'logid' => $logid ]; + return wfAppendQuery( wfScript( 'index' ), $query ); + } + + /** + * Use appropriate dispatch* method to obtain a redirection URL, + * and either: redirect, set a 404 error code and error message, + * or do nothing (if $mValue wasn't set) allowing the form to be + * displayed. + * + * @return bool True if a redirect was successfully handled. + */ + function dispatch() { + // the various namespaces supported by Special:Redirect + switch ( $this->mType ) { + case 'user': + $url = $this->dispatchUser(); + break; + case 'file': + $url = $this->dispatchFile(); + break; + case 'revision': + $url = $this->dispatchRevision(); + break; + case 'page': + $url = $this->dispatchPage(); + break; + case 'logid': + $url = $this->dispatchLog(); + break; + default: + $url = null; + break; + } + if ( $url ) { + $this->getOutput()->redirect( $url ); + + return true; + } + if ( !is_null( $this->mValue ) ) { + $this->getOutput()->setStatusCode( 404 ); + // Message: redirect-not-exists + $msg = $this->getMessagePrefix() . '-not-exists'; + + return Status::newFatal( $msg ); + } + + return false; + } + + protected function getFormFields() { + $mp = $this->getMessagePrefix(); + $ns = [ + // subpage => message + // Messages: redirect-user, redirect-page, redirect-revision, + // redirect-file, redirect-logid + 'user' => $mp . '-user', + 'page' => $mp . '-page', + 'revision' => $mp . '-revision', + 'file' => $mp . '-file', + 'logid' => $mp . '-logid', + ]; + $a = []; + $a['type'] = [ + 'type' => 'select', + 'label-message' => $mp . '-lookup', // Message: redirect-lookup + 'options' => [], + 'default' => current( array_keys( $ns ) ), + ]; + foreach ( $ns as $n => $m ) { + $m = $this->msg( $m )->text(); + $a['type']['options'][$m] = $n; + } + $a['value'] = [ + 'type' => 'text', + 'label-message' => $mp . '-value' // Message: redirect-value + ]; + // set the defaults according to the parsed subpage path + if ( !empty( $this->mType ) ) { + $a['type']['default'] = $this->mType; + } + if ( !empty( $this->mValue ) ) { + $a['value']['default'] = $this->mValue; + } + + return $a; + } + + public function onSubmit( array $data ) { + if ( !empty( $data['type'] ) && !empty( $data['value'] ) ) { + $this->setParameter( $data['type'] . '/' . $data['value'] ); + } + + /* if this returns false, will show the form */ + return $this->dispatch(); + } + + public function onSuccess() { + /* do nothing, we redirect in $this->dispatch if successful. */ + } + + protected function alterForm( HTMLForm $form ) { + /* display summary at top of page */ + $this->outputHeader(); + // tweak label on submit button + // Message: redirect-submit + $form->setSubmitTextMsg( $this->getMessagePrefix() . '-submit' ); + /* submit form every time */ + $form->setMethod( 'get' ); + } + + protected function getDisplayFormat() { + return 'ooui'; + } + + /** + * Return an array of subpages that this special page will accept. + * + * @return string[] subpages + */ + protected function getSubpagesForPrefixSearch() { + return [ + 'file', + 'page', + 'revision', + 'user', + 'logid', + ]; + } + + /** + * @return bool + */ + public function requiresWrite() { + return false; + } + + /** + * @return bool + */ + public function requiresUnblock() { + return false; + } + + protected function getGroupName() { + return 'redirects'; + } +} diff --git a/www/wiki/includes/specials/SpecialRemoveCredentials.php b/www/wiki/includes/specials/SpecialRemoveCredentials.php new file mode 100644 index 00000000..4efec035 --- /dev/null +++ b/www/wiki/includes/specials/SpecialRemoveCredentials.php @@ -0,0 +1,26 @@ +<?php + +use MediaWiki\Auth\AuthManager; + +/** + * Special change to remove credentials (such as a two-factor token). + */ +class SpecialRemoveCredentials extends SpecialChangeCredentials { + protected static $allowedActions = [ AuthManager::ACTION_REMOVE ]; + + protected static $messagePrefix = 'removecredentials'; + + protected static $loadUserData = false; + + public function __construct() { + parent::__construct( 'RemoveCredentials' ); + } + + protected function getDefaultAction( $subPage ) { + return AuthManager::ACTION_REMOVE; + } + + protected function getRequestBlacklist() { + return $this->getConfig()->get( 'RemoveCredentialsBlacklist' ); + } +} diff --git a/www/wiki/includes/specials/SpecialResetTokens.php b/www/wiki/includes/specials/SpecialResetTokens.php new file mode 100644 index 00000000..964a261a --- /dev/null +++ b/www/wiki/includes/specials/SpecialResetTokens.php @@ -0,0 +1,156 @@ +<?php +/** + * Implements Special:ResetTokens + * + * 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 + */ + +/** + * Let users reset tokens like the watchlist token. + * + * @ingroup SpecialPage + * @deprecated since 1.26 + */ +class SpecialResetTokens extends FormSpecialPage { + private $tokensList; + + public function __construct() { + parent::__construct( 'ResetTokens' ); + } + + public function doesWrites() { + return true; + } + + /** + * Returns the token information list for this page after running + * the hook and filtering out disabled preferences. + * + * @return array + */ + protected function getTokensList() { + if ( !isset( $this->tokensList ) ) { + $tokens = [ + [ 'preference' => 'watchlisttoken', 'label-message' => 'resettokens-watchlist-token' ], + ]; + Hooks::run( 'SpecialResetTokensTokens', [ &$tokens ] ); + + $hiddenPrefs = $this->getConfig()->get( 'HiddenPrefs' ); + $tokens = array_filter( $tokens, function ( $tok ) use ( $hiddenPrefs ) { + return !in_array( $tok['preference'], $hiddenPrefs ); + } ); + + $this->tokensList = $tokens; + } + + return $this->tokensList; + } + + public function execute( $par ) { + // This is a preferences page, so no user JS for y'all. + $this->getOutput()->disallowUserJs(); + $this->requireLogin(); + + parent::execute( $par ); + + $this->getOutput()->addReturnTo( SpecialPage::getTitleFor( 'Preferences' ) ); + } + + public function onSuccess() { + $this->getOutput()->wrapWikiMsg( + Html::successBox( '$1' ), + 'resettokens-done' + ); + } + + /** + * Display appropriate message if there's nothing to do. + * The submit button is also suppressed in this case (see alterForm()). + * @return array + */ + protected function getFormFields() { + $user = $this->getUser(); + $tokens = $this->getTokensList(); + + if ( $tokens ) { + $tokensForForm = []; + foreach ( $tokens as $tok ) { + $label = $this->msg( 'resettokens-token-label' ) + ->rawParams( $this->msg( $tok['label-message'] )->parse() ) + ->params( $user->getTokenFromOption( $tok['preference'] ) ) + ->escaped(); + $tokensForForm[$label] = $tok['preference']; + } + + $desc = [ + 'label-message' => 'resettokens-tokens', + 'type' => 'multiselect', + 'options' => $tokensForForm, + ]; + } else { + $desc = [ + 'label-message' => 'resettokens-no-tokens', + 'type' => 'info', + ]; + } + + return [ + 'tokens' => $desc, + ]; + } + + /** + * Suppress the submit button if there's nothing to do; + * provide additional message on it otherwise. + * @param HTMLForm $form + */ + protected function alterForm( HTMLForm $form ) { + if ( $this->getTokensList() ) { + $form->setSubmitTextMsg( 'resettokens-resetbutton' ); + } else { + $form->suppressDefaultSubmit(); + } + } + + protected function getDisplayFormat() { + return 'ooui'; + } + + public function onSubmit( array $formData ) { + if ( $formData['tokens'] ) { + $user = $this->getUser(); + foreach ( $formData['tokens'] as $tokenPref ) { + $user->resetTokenFromOption( $tokenPref ); + } + $user->saveSettings(); + + return true; + } + + return false; + } + + protected function getGroupName() { + return 'users'; + } + + public function isListed() { + return (bool)$this->getTokensList(); + } +} diff --git a/www/wiki/includes/specials/SpecialRevisiondelete.php b/www/wiki/includes/specials/SpecialRevisiondelete.php new file mode 100644 index 00000000..e7db9f5e --- /dev/null +++ b/www/wiki/includes/specials/SpecialRevisiondelete.php @@ -0,0 +1,689 @@ +<?php +/** + * Implements Special:Revisiondelete + * + * 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 + */ + +/** + * Special page allowing users with the appropriate permissions to view + * and hide revisions. Log items can also be hidden. + * + * @ingroup SpecialPage + */ +class SpecialRevisionDelete extends UnlistedSpecialPage { + /** @var bool Was the DB modified in this request */ + protected $wasSaved = false; + + /** @var bool True if the submit button was clicked, and the form was posted */ + private $submitClicked; + + /** @var array Target ID list */ + private $ids; + + /** @var string Archive name, for reviewing deleted files */ + private $archiveName; + + /** @var string Edit token for securing image views against XSS */ + private $token; + + /** @var Title Title object for target parameter */ + private $targetObj; + + /** @var string Deletion type, may be revision, archive, oldimage, filearchive, logging. */ + private $typeName; + + /** @var array Array of checkbox specs (message, name, deletion bits) */ + private $checks; + + /** @var array UI Labels about the current type */ + private $typeLabels; + + /** @var RevDelList RevDelList object, storing the list of items to be deleted/undeleted */ + private $revDelList; + + /** @var bool Whether user is allowed to perform the action */ + private $mIsAllowed; + + /** @var string */ + private $otherReason; + + /** + * UI labels for each type. + */ + private static $UILabels = [ + 'revision' => [ + '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( $request ); + } else { + $this->showForm(); + } + + if ( $user->isAllowed( 'deletedhistory' ) ) { + $qc = $this->getLogQueryCond(); + # Show relevant lines from the deletion log + $deleteLogPage = new LogPage( 'delete' ); + $output->addHTML( "<h2>" . $deleteLogPage->getName()->escaped() . "</h2>\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( "<h2>" . $suppressLogPage->getName()->escaped() . "</h2>\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() ) . + '</form>' + ); + + 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( "<strong>$1</strong>", [ $this->typeLabels['selected'], + $this->getLanguage()->formatNum( count( $this->ids ) ), $this->targetObj->getPrefixedText() ] ); + + $this->addHelpLink( 'Help:RevisionDelete' ); + $out->addHTML( "<ul>" ); + + $numRevisions = 0; + // Live revisions... + $list = $this->getList(); + // phpcs:ignore Generic.CodeAnalysis.ForLoopWithTestFunctionCall + for ( $list->reset(); $list->current(); $list->next() ) { + $item = $list->current(); + + if ( !$item->canView() ) { + if ( !$this->submitClicked ) { + throw new PermissionsError( 'suppressrevision' ); + } + $userAllowed = false; + } + + $numRevisions++; + $out->addHTML( $item->getHTML() ); + } + + if ( !$numRevisions ) { + throw new ErrorPageError( 'revdelete-nooldid-title', 'revdelete-nooldid-text' ); + } + + $out->addHTML( "</ul>" ); + // 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' ); + + $conf = $this->getConfig(); + $oldCommentSchema = $conf->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD; + + $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' ) . + "<tr>\n" . + '<td class="mw-label">' . + Xml::label( $this->msg( 'revdelete-log' )->text(), 'wpRevDeleteReasonList' ) . + '</td>' . + '<td class="mw-input">' . + Xml::listDropDown( 'wpRevDeleteReasonList', + $this->msg( 'revdelete-reason-dropdown' )->inContentLanguage()->text(), + $this->msg( 'revdelete-reasonotherlist' )->inContentLanguage()->text(), + $this->getRequest()->getText( 'wpRevDeleteReasonList', 'other' ), 'wpReasonDropDown' + ) . + '</td>' . + "</tr><tr>\n" . + '<td class="mw-label">' . + Xml::label( $this->msg( 'revdelete-otherreason' )->text(), 'wpReason' ) . + '</td>' . + '<td class="mw-input">' . + 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 (or 255 UTF-8 bytes for old schema). + // "- 155" is to leave room for the 'wpRevDeleteReasonList' value. + 'maxlength' => $oldCommentSchema ? 100 : CommentStore::COMMENT_CHARACTER_LIMIT - 155, + ] ) . + '</td>' . + "</tr><tr>\n" . + '<td></td>' . + '<td class="mw-submit">' . + Xml::submitButton( $this->msg( 'revdelete-submit', $numRevisions )->text(), + [ 'name' => 'wpSubmit' ] ) . + '</td>' . + "</tr>\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( + "<strong>$1</strong>\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 = '<table>'; + // 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 = "<b>$innerHTML</b>"; + } + + $line = Xml::tags( 'td', [ 'class' => 'mw-input' ], $innerHTML ); + $html .= "<tr>$line</tr>\n"; + } + } else { + // Otherwise, use tri-state radios + $html .= '<tr>'; + $html .= '<th class="mw-revdel-checkbox">' + . $this->msg( 'revdelete-radio-same' )->escaped() . '</th>'; + $html .= '<th class="mw-revdel-checkbox">' + . $this->msg( 'revdelete-radio-unset' )->escaped() . '</th>'; + $html .= '<th class="mw-revdel-checkbox">' + . $this->msg( 'revdelete-radio-set' )->escaped() . '</th>'; + $html .= "<th></th></tr>\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 = '<td class="mw-revdel-checkbox">' . Xml::radio( $name, -1, $selected == -1 ) . '</td>'; + $line .= '<td class="mw-revdel-checkbox">' . Xml::radio( $name, 0, $selected == 0 ) . '</td>'; + $line .= '<td class="mw-revdel-checkbox">' . Xml::radio( $name, 1, $selected == 1 ) . '</td>'; + $label = $this->msg( $message )->escaped(); + if ( $field == Revision::DELETED_RESTRICTED ) { + $label = "<b>$label</b>"; + } + $line .= "<td>$label</td>"; + $html .= "<tr>$line</tr>\n"; + } + } + + $html .= '</table>'; + + 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( + "<div class=\"successbox\">\n$1\n</div>", + $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()->addWikiText( + Html::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/www/wiki/includes/specials/SpecialRunJobs.php b/www/wiki/includes/specials/SpecialRunJobs.php new file mode 100644 index 00000000..375694be --- /dev/null +++ b/www/wiki/includes/specials/SpecialRunJobs.php @@ -0,0 +1,121 @@ +<?php +/** + * Implements Special:RunJobs + * + * 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 + */ + +use MediaWiki\Logger\LoggerFactory; + +/** + * Special page designed for running background tasks (internal use only) + * + * @ingroup SpecialPage + */ +class SpecialRunJobs extends UnlistedSpecialPage { + public function __construct() { + parent::__construct( 'RunJobs' ); + } + + public function doesWrites() { + return true; + } + + public function execute( $par = '' ) { + $this->getOutput()->disable(); + + if ( wfReadOnly() ) { + wfHttpError( 423, 'Locked', 'Wiki is in read-only mode.' ); + return; + } + + // Validate request method + if ( !$this->getRequest()->wasPosted() ) { + wfHttpError( 400, 'Bad Request', 'Request must be POSTed.' ); + return; + } + + // Validate request parameters + $optional = [ 'maxjobs' => 0, 'maxtime' => 30, 'type' => false, 'async' => true ]; + $required = array_flip( [ 'title', 'tasks', 'signature', 'sigexpiry' ] ); + $params = array_intersect_key( $this->getRequest()->getValues(), $required + $optional ); + $missing = array_diff_key( $required, $params ); + if ( count( $missing ) ) { + wfHttpError( 400, 'Bad Request', + 'Missing parameters: ' . implode( ', ', array_keys( $missing ) ) + ); + return; + } + + // Validate request signature + $squery = $params; + unset( $squery['signature'] ); + $correctSignature = self::getQuerySignature( $squery, $this->getConfig()->get( 'SecretKey' ) ); + $providedSignature = $params['signature']; + $verified = is_string( $providedSignature ) + && hash_equals( $correctSignature, $providedSignature ); + if ( !$verified || $params['sigexpiry'] < time() ) { + wfHttpError( 400, 'Bad Request', 'Invalid or stale signature provided.' ); + return; + } + + // Apply any default parameter values + $params += $optional; + + if ( $params['async'] ) { + // HTTP 202 Accepted + HttpStatus::header( 202 ); + // Clients are meant to disconnect without waiting for the full response. + // Let the page output happen before the jobs start, so that clients know it's + // safe to disconnect. MediaWiki::preOutputCommit() calls ignore_user_abort() + // or similar to make sure we stay alive to run the deferred update. + DeferredUpdates::addUpdate( + new TransactionRoundDefiningUpdate( + function () use ( $params ) { + $this->doRun( $params ); + }, + __METHOD__ + ), + DeferredUpdates::POSTSEND + ); + } else { + $this->doRun( $params ); + print "Done\n"; + } + } + + protected function doRun( array $params ) { + $runner = new JobRunner( LoggerFactory::getInstance( 'runJobs' ) ); + $runner->run( [ + 'type' => $params['type'], + 'maxJobs' => $params['maxjobs'] ?: 1, + 'maxTime' => $params['maxtime'] ?: 30 + ] ); + } + + /** + * @param array $query + * @param string $secretKey + * @return string + */ + public static function getQuerySignature( array $query, $secretKey ) { + ksort( $query ); // stable order + return hash_hmac( 'sha1', wfArrayToCgi( $query ), $secretKey ); + } +} diff --git a/www/wiki/includes/specials/SpecialSearch.php b/www/wiki/includes/specials/SpecialSearch.php new file mode 100644 index 00000000..f8268445 --- /dev/null +++ b/www/wiki/includes/specials/SpecialSearch.php @@ -0,0 +1,718 @@ +<?php +/** + * Implements Special:Search + * + * Copyright © 2004 Brion Vibber <brion@pobox.com> + * + * 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 + */ + +use MediaWiki\MediaWikiServices; +use MediaWiki\Widget\Search\BasicSearchResultSetWidget; +use MediaWiki\Widget\Search\FullSearchResultWidget; +use MediaWiki\Widget\Search\InterwikiSearchResultWidget; +use MediaWiki\Widget\Search\InterwikiSearchResultSetWidget; +use MediaWiki\Widget\Search\SimpleSearchResultWidget; +use MediaWiki\Widget\Search\SimpleSearchResultSetWidget; + +/** + * implements Special:Search - Run text & title search and display the output + * @ingroup SpecialPage + */ +class SpecialSearch extends SpecialPage { + /** + * Current search profile. Search profile is just a name that identifies + * the active search tab on the search page (content, discussions...) + * For users tt replaces the set of enabled namespaces from the query + * string when applicable. Extensions can add new profiles with hooks + * with custom search options just for that profile. + * @var null|string + */ + protected $profile; + + /** @var SearchEngine Search engine */ + protected $searchEngine; + + /** @var string Search engine type, if not default */ + protected $searchEngineType; + + /** @var array For links */ + protected $extraParams = []; + + /** + * @var string The prefix url parameter. Set on the searcher and the + * is expected to treat it as prefix filter on titles. + */ + protected $mPrefix; + + /** + * @var int + */ + protected $limit, $offset; + + /** + * @var array + */ + protected $namespaces; + + /** + * @var string + */ + protected $fulltext; + + /** + * @var bool + */ + protected $runSuggestion = true; + + /** + * Search engine configurations. + * @var SearchEngineConfig + */ + protected $searchConfig; + + const NAMESPACES_CURRENT = 'sense'; + + public function __construct() { + parent::__construct( 'Search' ); + $this->searchConfig = MediaWikiServices::getInstance()->getSearchEngineConfig(); + } + + /** + * Entry point + * + * @param string $par + */ + public function execute( $par ) { + $request = $this->getRequest(); + $out = $this->getOutput(); + + // Fetch the search term + $term = str_replace( "\n", " ", $request->getText( 'search' ) ); + + // Historically search terms have been accepted not only in the search query + // parameter, but also as part of the primary url. This can have PII implications + // in releasing page view data. As such issue a 301 redirect to the correct + // URL. + if ( strlen( $par ) && !strlen( $term ) ) { + $query = $request->getValues(); + unset( $query['title'] ); + // Strip underscores from title parameter; most of the time we'll want + // text form here. But don't strip underscores from actual text params! + $query['search'] = str_replace( '_', ' ', $par ); + $out->redirect( $this->getPageTitle()->getFullURL( $query ), 301 ); + return; + } + + // Need to load selected namespaces before handling nsRemember + $this->load(); + // TODO: This performs database actions on GET request, which is going to + // be a problem for our multi-datacenter work. + if ( !is_null( $request->getVal( 'nsRemember' ) ) ) { + $this->saveNamespaces(); + // Remove the token from the URL to prevent the user from inadvertently + // exposing it (e.g. by pasting it into a public wiki page) or undoing + // later settings changes (e.g. by reloading the page). + $query = $request->getValues(); + unset( $query['title'], $query['nsRemember'] ); + $out->redirect( $this->getPageTitle()->getFullURL( $query ) ); + return; + } + + $this->searchEngineType = $request->getVal( 'srbackend' ); + if ( + !$request->getVal( 'fulltext' ) && + $request->getVal( 'offset' ) === null + ) { + $url = $this->goResult( $term ); + if ( $url !== null ) { + // successful 'go' + $out->redirect( $url ); + return; + } + // No match. If it could plausibly be a title + // run the No go match hook. + $title = Title::newFromText( $term ); + if ( !is_null( $title ) ) { + Hooks::run( 'SpecialSearchNogomatch', [ &$title ] ); + } + } + + $this->setupPage( $term ); + + if ( $this->getConfig()->get( 'DisableTextSearch' ) ) { + $searchForwardUrl = $this->getConfig()->get( 'SearchForwardUrl' ); + if ( $searchForwardUrl ) { + $url = str_replace( '$1', urlencode( $term ), $searchForwardUrl ); + $out->redirect( $url ); + } else { + $out->addHTML( + "<fieldset>" . + "<legend>" . + $this->msg( 'search-external' )->escaped() . + "</legend>" . + "<p class='mw-searchdisabled'>" . + $this->msg( 'searchdisabled' )->escaped() . + "</p>" . + $this->msg( 'googlesearch' )->rawParams( + htmlspecialchars( $term ), + 'UTF-8', + $this->msg( 'searchbutton' )->escaped() + )->text() . + "</fieldset>" + ); + } + + return; + } + + $this->showResults( $term ); + } + + /** + * Set up basic search parameters from the request and user settings. + * + * @see tests/phpunit/includes/specials/SpecialSearchTest.php + */ + public function load() { + $request = $this->getRequest(); + list( $this->limit, $this->offset ) = $request->getLimitOffset( 20, '' ); + $this->mPrefix = $request->getVal( 'prefix', '' ); + + $user = $this->getUser(); + + # Extract manually requested namespaces + $nslist = $this->powerSearch( $request ); + if ( !count( $nslist ) ) { + # Fallback to user preference + $nslist = $this->searchConfig->userNamespaces( $user ); + } + + $profile = null; + if ( !count( $nslist ) ) { + $profile = 'default'; + } + + $profile = $request->getVal( 'profile', $profile ); + $profiles = $this->getSearchProfiles(); + if ( $profile === null ) { + // BC with old request format + $profile = 'advanced'; + foreach ( $profiles as $key => $data ) { + if ( $nslist === $data['namespaces'] && $key !== 'advanced' ) { + $profile = $key; + } + } + $this->namespaces = $nslist; + } elseif ( $profile === 'advanced' ) { + $this->namespaces = $nslist; + } else { + if ( isset( $profiles[$profile]['namespaces'] ) ) { + $this->namespaces = $profiles[$profile]['namespaces']; + } else { + // Unknown profile requested + $profile = 'default'; + $this->namespaces = $profiles['default']['namespaces']; + } + } + + $this->fulltext = $request->getVal( 'fulltext' ); + $this->runSuggestion = (bool)$request->getVal( 'runsuggestion', true ); + $this->profile = $profile; + } + + /** + * If an exact title match can be found, jump straight ahead to it. + * + * @param string $term + * @return string|null The url to redirect to, or null if no redirect. + */ + public function goResult( $term ) { + # If the string cannot be used to create a title + if ( is_null( Title::newFromText( $term ) ) ) { + return null; + } + # If there's an exact or very near match, jump right there. + $title = $this->getSearchEngine() + ->getNearMatcher( $this->getConfig() )->getNearMatch( $term ); + if ( is_null( $title ) ) { + return null; + } + $url = null; + if ( !Hooks::run( 'SpecialSearchGoResult', [ $term, $title, &$url ] ) ) { + return null; + } + + return $url === null ? $title->getFullUrlForRedirect() : $url; + } + + /** + * @param string $term + */ + public function showResults( $term ) { + global $wgContLang; + + if ( $this->searchEngineType !== null ) { + $this->setExtraParam( 'srbackend', $this->searchEngineType ); + } + + $out = $this->getOutput(); + $formWidget = new MediaWiki\Widget\Search\SearchFormWidget( + $this, + $this->searchConfig, + $this->getSearchProfiles() + ); + $filePrefix = $wgContLang->getFormattedNsText( NS_FILE ) . ':'; + if ( trim( $term ) === '' || $filePrefix === trim( $term ) ) { + // Empty query -- straight view of search form + if ( !Hooks::run( 'SpecialSearchResultsPrepend', [ $this, $out, $term ] ) ) { + # Hook requested termination + return; + } + $out->enableOOUI(); + // The form also contains the 'Showing results 0 - 20 of 1234' so we can + // only do the form render here for the empty $term case. Rendering + // the form when a search is provided is repeated below. + $out->addHTML( $formWidget->render( + $this->profile, $term, 0, 0, $this->offset, $this->isPowerSearch() + ) ); + return; + } + + $search = $this->getSearchEngine(); + $search->setFeatureData( 'rewrite', $this->runSuggestion ); + $search->setLimitOffset( $this->limit, $this->offset ); + $search->setNamespaces( $this->namespaces ); + $search->prefix = $this->mPrefix; + $term = $search->transformSearchTerm( $term ); + + Hooks::run( 'SpecialSearchSetupEngine', [ $this, $this->profile, $search ] ); + if ( !Hooks::run( 'SpecialSearchResultsPrepend', [ $this, $out, $term ] ) ) { + # Hook requested termination + return; + } + + $title = Title::newFromText( $term ); + $showSuggestion = $title === null || !$title->isKnown(); + $search->setShowSuggestion( $showSuggestion ); + + // fetch search results + $rewritten = $search->replacePrefixes( $term ); + + $titleMatches = $search->searchTitle( $rewritten ); + $textMatches = $search->searchText( $rewritten ); + + $textStatus = null; + if ( $textMatches instanceof Status ) { + $textStatus = $textMatches; + $textMatches = $textStatus->getValue(); + } + + // Get number of results + $titleMatchesNum = $textMatchesNum = $numTitleMatches = $numTextMatches = 0; + if ( $titleMatches ) { + $titleMatchesNum = $titleMatches->numRows(); + $numTitleMatches = $titleMatches->getTotalHits(); + } + if ( $textMatches ) { + $textMatchesNum = $textMatches->numRows(); + $numTextMatches = $textMatches->getTotalHits(); + if ( $textMatchesNum > 0 ) { + $search->augmentSearchResults( $textMatches ); + } + } + $num = $titleMatchesNum + $textMatchesNum; + $totalRes = $numTitleMatches + $numTextMatches; + + // start rendering the page + $out->enableOOUI(); + $out->addHTML( $formWidget->render( + $this->profile, $term, $num, $totalRes, $this->offset, $this->isPowerSearch() + ) ); + + // did you mean... suggestions + if ( $textMatches ) { + $dymWidget = new MediaWiki\Widget\Search\DidYouMeanWidget( $this ); + $out->addHTML( $dymWidget->render( $term, $textMatches ) ); + } + + $hasErrors = $textStatus && $textStatus->getErrors() !== []; + $hasOtherResults = $textMatches && + $textMatches->hasInterwikiResults( SearchResultSet::INLINE_RESULTS ); + + if ( $textMatches && $textMatches->hasInterwikiResults( SearchResultSet::SECONDARY_RESULTS ) ) { + $out->addHTML( '<div class="searchresults mw-searchresults-has-iw">' ); + } else { + $out->addHTML( '<div class="searchresults">' ); + } + + if ( $hasErrors ) { + list( $error, $warning ) = $textStatus->splitByErrorType(); + if ( $error->getErrors() ) { + $out->addHTML( Html::errorBox( + $error->getHTML( 'search-error' ) + ) ); + } + if ( $warning->getErrors() ) { + $out->addHTML( Html::warningBox( + $warning->getHTML( 'search-warning' ) + ) ); + } + } + + // Show the create link ahead + $this->showCreateLink( $title, $num, $titleMatches, $textMatches ); + + Hooks::run( 'SpecialSearchResults', [ $term, &$titleMatches, &$textMatches ] ); + + // If we have no results and have not already displayed an error message + if ( $num === 0 && !$hasErrors ) { + $out->wrapWikiMsg( "<p class=\"mw-search-nonefound\">\n$1</p>", [ + $hasOtherResults ? 'search-nonefound-thiswiki' : 'search-nonefound', + wfEscapeWikiText( $term ) + ] ); + } + + // Although $num might be 0 there can still be secondary or inline + // results to display. + $linkRenderer = $this->getLinkRenderer(); + $mainResultWidget = new FullSearchResultWidget( $this, $linkRenderer ); + + // Default (null) on. Can be explicitly disabled. + if ( $search->getFeatureData( 'enable-new-crossproject-page' ) !== false ) { + $sidebarResultWidget = new InterwikiSearchResultWidget( $this, $linkRenderer ); + $sidebarResultsWidget = new InterwikiSearchResultSetWidget( + $this, + $sidebarResultWidget, + $linkRenderer, + MediaWikiServices::getInstance()->getInterwikiLookup(), + $search->getFeatureData( 'show-multimedia-search-results' ) + ); + } else { + $sidebarResultWidget = new SimpleSearchResultWidget( $this, $linkRenderer ); + $sidebarResultsWidget = new SimpleSearchResultSetWidget( + $this, + $sidebarResultWidget, + $linkRenderer, + MediaWikiServices::getInstance()->getInterwikiLookup() + ); + } + + $widget = new BasicSearchResultSetWidget( $this, $mainResultWidget, $sidebarResultsWidget ); + + $out->addHTML( $widget->render( + $term, $this->offset, $titleMatches, $textMatches + ) ); + + if ( $titleMatches ) { + $titleMatches->free(); + } + + if ( $textMatches ) { + $textMatches->free(); + } + + $out->addHTML( '<div class="mw-search-visualclear"></div>' ); + + // prev/next links + if ( $totalRes > $this->limit || $this->offset ) { + // Allow matches to define the correct offset, as interleaved + // AB testing may require a different next page offset. + if ( $textMatches && $textMatches->getOffset() !== null ) { + $offset = $textMatches->getOffset(); + } else { + $offset = $this->offset; + } + + $prevnext = $this->getLanguage()->viewPrevNext( + $this->getPageTitle(), + $offset, + $this->limit, + $this->powerSearchOptions() + [ 'search' => $term ], + $this->limit + $this->offset >= $totalRes + ); + $out->addHTML( "<p class='mw-search-pager-bottom'>{$prevnext}</p>\n" ); + } + + // Close <div class='searchresults'> + $out->addHTML( "</div>" ); + + Hooks::run( 'SpecialSearchResultsAppend', [ $this, $out, $term ] ); + } + + /** + * @param Title $title + * @param int $num The number of search results found + * @param null|SearchResultSet $titleMatches Results from title search + * @param null|SearchResultSet $textMatches Results from text search + */ + protected function showCreateLink( $title, $num, $titleMatches, $textMatches ) { + // show direct page/create link if applicable + + // Check DBkey !== '' in case of fragment link only. + if ( is_null( $title ) || $title->getDBkey() === '' + || ( $titleMatches !== null && $titleMatches->searchContainedSyntax() ) + || ( $textMatches !== null && $textMatches->searchContainedSyntax() ) + ) { + // invalid title + // preserve the paragraph for margins etc... + $this->getOutput()->addHTML( '<p></p>' ); + + return; + } + + $messageName = 'searchmenu-new-nocreate'; + $linkClass = 'mw-search-createlink'; + + if ( !$title->isExternal() ) { + if ( $title->isKnown() ) { + $messageName = 'searchmenu-exists'; + $linkClass = 'mw-search-exists'; + } elseif ( ContentHandler::getForTitle( $title )->supportsDirectEditing() + && $title->quickUserCan( 'create', $this->getUser() ) + ) { + $messageName = 'searchmenu-new'; + } + } + + $params = [ + $messageName, + wfEscapeWikiText( $title->getPrefixedText() ), + Message::numParam( $num ) + ]; + Hooks::run( 'SpecialSearchCreateLink', [ $title, &$params ] ); + + // Extensions using the hook might still return an empty $messageName + if ( $messageName ) { + $this->getOutput()->wrapWikiMsg( "<p class=\"$linkClass\">\n$1</p>", $params ); + } else { + // preserve the paragraph for margins etc... + $this->getOutput()->addHTML( '<p></p>' ); + } + } + + /** + * Sets up everything for the HTML output page including styles, javascript, + * page title, etc. + * + * @param string $term + */ + protected function setupPage( $term ) { + $out = $this->getOutput(); + + $this->setHeaders(); + $this->outputHeader(); + // TODO: Is this true? The namespace remember uses a user token + // on save. + $out->allowClickjacking(); + $this->addHelpLink( 'Help:Searching' ); + + if ( strval( $term ) !== '' ) { + $out->setPageTitle( $this->msg( 'searchresults' ) ); + $out->setHTMLTitle( $this->msg( 'pagetitle' ) + ->plaintextParams( $this->msg( 'searchresults-title' )->plaintextParams( $term )->text() ) + ->inContentLanguage()->text() + ); + } + + $out->addJsConfigVars( [ 'searchTerm' => $term ] ); + $out->addModules( 'mediawiki.special.search' ); + $out->addModuleStyles( [ + 'mediawiki.special', 'mediawiki.special.search.styles', 'mediawiki.ui', 'mediawiki.ui.button', + 'mediawiki.ui.input', 'mediawiki.widgets.SearchInputWidget.styles', + ] ); + } + + /** + * Return true if current search is a power (advanced) search + * + * @return bool + */ + protected function isPowerSearch() { + return $this->profile === 'advanced'; + } + + /** + * Extract "power search" namespace settings from the request object, + * returning a list of index numbers to search. + * + * @param WebRequest &$request + * @return array + */ + protected function powerSearch( &$request ) { + $arr = []; + foreach ( $this->searchConfig->searchableNamespaces() as $ns => $name ) { + if ( $request->getCheck( 'ns' . $ns ) ) { + $arr[] = $ns; + } + } + + return $arr; + } + + /** + * Reconstruct the 'power search' options for links + * TODO: Instead of exposing this publicly, could we instead expose + * a function for creating search links? + * + * @return array + */ + public function powerSearchOptions() { + $opt = []; + if ( $this->isPowerSearch() ) { + foreach ( $this->namespaces as $n ) { + $opt['ns' . $n] = 1; + } + } else { + $opt['profile'] = $this->profile; + } + + return $opt + $this->extraParams; + } + + /** + * Save namespace preferences when we're supposed to + * + * @return bool Whether we wrote something + */ + protected function saveNamespaces() { + $user = $this->getUser(); + $request = $this->getRequest(); + + if ( $user->isLoggedIn() && + $user->matchEditToken( + $request->getVal( 'nsRemember' ), + 'searchnamespace', + $request + ) && !wfReadOnly() + ) { + // Reset namespace preferences: namespaces are not searched + // when they're not mentioned in the URL parameters. + foreach ( MWNamespace::getValidNamespaces() as $n ) { + $user->setOption( 'searchNs' . $n, false ); + } + // The request parameters include all the namespaces to be searched. + // Even if they're the same as an existing profile, they're not eaten. + foreach ( $this->namespaces as $n ) { + $user->setOption( 'searchNs' . $n, true ); + } + + DeferredUpdates::addCallableUpdate( function () use ( $user ) { + $user->saveSettings(); + } ); + + return true; + } + + return false; + } + + /** + * @return array + */ + protected function getSearchProfiles() { + // Builds list of Search Types (profiles) + $nsAllSet = array_keys( $this->searchConfig->searchableNamespaces() ); + $defaultNs = $this->searchConfig->defaultNamespaces(); + $profiles = [ + 'default' => [ + 'message' => 'searchprofile-articles', + 'tooltip' => 'searchprofile-articles-tooltip', + 'namespaces' => $defaultNs, + 'namespace-messages' => $this->searchConfig->namespacesAsText( + $defaultNs + ), + ], + 'images' => [ + 'message' => 'searchprofile-images', + 'tooltip' => 'searchprofile-images-tooltip', + 'namespaces' => [ NS_FILE ], + ], + 'all' => [ + 'message' => 'searchprofile-everything', + 'tooltip' => 'searchprofile-everything-tooltip', + 'namespaces' => $nsAllSet, + ], + 'advanced' => [ + 'message' => 'searchprofile-advanced', + 'tooltip' => 'searchprofile-advanced-tooltip', + 'namespaces' => self::NAMESPACES_CURRENT, + ] + ]; + + Hooks::run( 'SpecialSearchProfiles', [ &$profiles ] ); + + foreach ( $profiles as &$data ) { + if ( !is_array( $data['namespaces'] ) ) { + continue; + } + sort( $data['namespaces'] ); + } + + return $profiles; + } + + /** + * @since 1.18 + * + * @return SearchEngine + */ + public function getSearchEngine() { + if ( $this->searchEngine === null ) { + $this->searchEngine = $this->searchEngineType ? + MediaWikiServices::getInstance()->getSearchEngineFactory()->create( $this->searchEngineType ) : + MediaWikiServices::getInstance()->newSearchEngine(); + } + + return $this->searchEngine; + } + + /** + * Current search profile. + * @return null|string + */ + function getProfile() { + return $this->profile; + } + + /** + * Current namespaces. + * @return array + */ + function getNamespaces() { + return $this->namespaces; + } + + /** + * Users of hook SpecialSearchSetupEngine can use this to + * add more params to links to not lose selection when + * user navigates search results. + * @since 1.18 + * + * @param string $key + * @param mixed $value + */ + public function setExtraParam( $key, $value ) { + $this->extraParams[$key] = $value; + } + + protected function getGroupName() { + return 'pages'; + } +} diff --git a/www/wiki/includes/specials/SpecialShortpages.php b/www/wiki/includes/specials/SpecialShortpages.php new file mode 100644 index 00000000..d90f72c2 --- /dev/null +++ b/www/wiki/includes/specials/SpecialShortpages.php @@ -0,0 +1,178 @@ +<?php +/** + * Implements Special:Shortpages + * + * 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 + */ + +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * SpecialShortpages extends QueryPage. It is used to return the shortest + * pages in the database. + * + * @ingroup SpecialPage + */ +class ShortPagesPage extends QueryPage { + + function __construct( $name = 'Shortpages' ) { + parent::__construct( $name ); + } + + function isSyndicated() { + return false; + } + + public function getQueryInfo() { + $config = $this->getConfig(); + $blacklist = $config->get( 'ShortPagesNamespaceBlacklist' ); + $tables = [ 'page' ]; + $conds = [ + 'page_namespace' => array_diff( MWNamespace::getContentNamespaces(), $blacklist ), + 'page_is_redirect' => 0 + ]; + $joinConds = []; + $options = [ 'USE INDEX' => [ 'page' => 'page_redirect_namespace_len' ] ]; + + // Allow extensions to modify the query + Hooks::run( 'ShortPagesQuery', [ &$tables, &$conds, &$joinConds, &$options ] ); + + return [ + 'tables' => $tables, + 'fields' => [ + 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'page_len' + ], + 'conds' => $conds, + 'join_conds' => $joinConds, + 'options' => $options + ]; + } + + public function reallyDoQuery( $limit, $offset = false ) { + $fname = static::class . '::reallyDoQuery'; + $dbr = $this->getRecacheDB(); + $query = $this->getQueryInfo(); + $order = $this->getOrderFields(); + + if ( $this->sortDescending() ) { + foreach ( $order as &$field ) { + $field .= ' DESC'; + } + } + + $tables = isset( $query['tables'] ) ? (array)$query['tables'] : []; + $fields = isset( $query['fields'] ) ? (array)$query['fields'] : []; + $conds = isset( $query['conds'] ) ? (array)$query['conds'] : []; + $options = isset( $query['options'] ) ? (array)$query['options'] : []; + $join_conds = isset( $query['join_conds'] ) ? (array)$query['join_conds'] : []; + + if ( $limit !== false ) { + $options['LIMIT'] = intval( $limit ); + } + + if ( $offset !== false ) { + $options['OFFSET'] = intval( $offset ); + } + + $namespaces = $conds['page_namespace']; + if ( count( $namespaces ) === 1 ) { + $options['ORDER BY'] = $order; + $res = $dbr->select( $tables, $fields, $conds, $fname, + $options, $join_conds + ); + } else { + unset( $conds['page_namespace'] ); + $options['INNER ORDER BY'] = $order; + $options['ORDER BY'] = [ 'value' . ( $this->sortDescending() ? ' DESC' : '' ) ]; + $sql = $dbr->unionConditionPermutations( + $tables, + $fields, + [ 'page_namespace' => $namespaces ], + $conds, + $fname, + $options, + $join_conds + ); + $res = $dbr->query( $sql, $fname ); + } + + return $res; + } + + function getOrderFields() { + return [ 'page_len' ]; + } + + /** + * @param IDatabase $db + * @param IResultWrapper $res + */ + function preprocessResults( $db, $res ) { + $this->executeLBFromResultWrapper( $res ); + } + + function sortDescending() { + return false; + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + $dm = $this->getLanguage()->getDirMark(); + + $title = Title::makeTitleSafe( $result->namespace, $result->title ); + if ( !$title ) { + return Html::element( 'span', [ 'class' => 'mw-invalidtitle' ], + Linker::getInvalidTitleDescription( $this->getContext(), $result->namespace, $result->title ) ); + } + + $linkRenderer = $this->getLinkRenderer(); + $hlink = $linkRenderer->makeKnownLink( + $title, + $this->msg( 'hist' )->text(), + [], + [ 'action' => 'history' ] + ); + $hlinkInParentheses = $this->msg( 'parentheses' )->rawParams( $hlink )->escaped(); + + if ( $this->isCached() ) { + $plink = $linkRenderer->makeLink( $title ); + $exists = $title->exists(); + } else { + $plink = $linkRenderer->makeKnownLink( $title ); + $exists = true; + } + + $size = $this->msg( 'nbytes' )->numParams( $result->value )->escaped(); + + return $exists + ? "${hlinkInParentheses} {$dm}{$plink} {$dm}[{$size}]" + : "<del>${hlinkInParentheses} {$dm}{$plink} {$dm}[{$size}]</del>"; + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialSpecialpages.php b/www/wiki/includes/specials/SpecialSpecialpages.php new file mode 100644 index 00000000..4f290822 --- /dev/null +++ b/www/wiki/includes/specials/SpecialSpecialpages.php @@ -0,0 +1,158 @@ +<?php +/** + * Implements Special:Specialpages + * + * 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 + */ + +/** + * A special page that lists special pages + * + * @ingroup SpecialPage + */ +class SpecialSpecialpages extends UnlistedSpecialPage { + + function __construct() { + parent::__construct( 'Specialpages' ); + } + + function execute( $par ) { + $out = $this->getOutput(); + $this->setHeaders(); + $this->outputHeader(); + $out->allowClickjacking(); + $out->addModuleStyles( 'mediawiki.special' ); + + $groups = $this->getPageGroups(); + + if ( $groups === false ) { + return; + } + + $this->addHelpLink( 'Help:Special pages' ); + $this->outputPageList( $groups ); + } + + private function getPageGroups() { + $pages = SpecialPageFactory::getUsablePages( $this->getUser() ); + + if ( !count( $pages ) ) { + # Yeah, that was pointless. Thanks for coming. + return false; + } + + /** Put them into a sortable array */ + $groups = []; + /** @var SpecialPage $page */ + foreach ( $pages as $page ) { + if ( $page->isListed() ) { + $group = $page->getFinalGroupName(); + if ( !isset( $groups[$group] ) ) { + $groups[$group] = []; + } + $groups[$group][$page->getDescription()] = [ + $page->getPageTitle(), + $page->isRestricted(), + $page->isCached() + ]; + } + } + + /** Sort */ + foreach ( $groups as $group => $sortedPages ) { + ksort( $groups[$group] ); + } + + /** Always move "other" to end */ + if ( array_key_exists( 'other', $groups ) ) { + $other = $groups['other']; + unset( $groups['other'] ); + $groups['other'] = $other; + } + + return $groups; + } + + private function outputPageList( $groups ) { + $out = $this->getOutput(); + + $includesRestrictedPages = false; + $includesCachedPages = false; + + foreach ( $groups as $group => $sortedPages ) { + $out->wrapWikiMsg( + "<h2 class=\"mw-specialpagesgroup\" id=\"mw-specialpagesgroup-$group\">$1</h2>\n", + "specialpages-group-$group" + ); + $out->addHTML( + Html::openElement( 'div', [ 'class' => 'mw-specialpages-list' ] ) + . '<ul>' + ); + foreach ( $sortedPages as $desc => $specialpage ) { + list( $title, $restricted, $cached ) = $specialpage; + + $pageClasses = []; + if ( $cached ) { + $includesCachedPages = true; + $pageClasses[] = 'mw-specialpagecached'; + } + if ( $restricted ) { + $includesRestrictedPages = true; + $pageClasses[] = 'mw-specialpagerestricted'; + } + + $link = $this->getLinkRenderer()->makeKnownLink( $title, $desc ); + $out->addHTML( Html::rawElement( + 'li', + [ 'class' => implode( ' ', $pageClasses ) ], + $link + ) . "\n" ); + } + $out->addHTML( + Html::closeElement( 'ul' ) . + Html::closeElement( 'div' ) + ); + } + + // add legend + $notes = []; + if ( $includesRestrictedPages ) { + $restricedMsg = $this->msg( 'specialpages-note-restricted' ); + if ( !$restricedMsg->isDisabled() ) { + $notes[] = $restricedMsg->plain(); + } + } + if ( $includesCachedPages ) { + $cachedMsg = $this->msg( 'specialpages-note-cached' ); + if ( !$cachedMsg->isDisabled() ) { + $notes[] = $cachedMsg->plain(); + } + } + if ( $notes !== [] ) { + $out->wrapWikiMsg( + "<h2 class=\"mw-specialpages-note-top\">$1</h2>", 'specialpages-note-top' + ); + $out->addWikiText( + "<div class=\"mw-specialpages-notes\">\n" . + implode( "\n", $notes ) . + "\n</div>" + ); + } + } +} diff --git a/www/wiki/includes/specials/SpecialStatistics.php b/www/wiki/includes/specials/SpecialStatistics.php new file mode 100644 index 00000000..a60549bf --- /dev/null +++ b/www/wiki/includes/specials/SpecialStatistics.php @@ -0,0 +1,307 @@ +<?php +/** + * Implements Special:Statistics + * + * 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 + */ + +/** + * Special page lists various statistics, including the contents of + * `site_stats`, plus page view details if enabled + * + * @ingroup SpecialPage + */ +class SpecialStatistics extends SpecialPage { + private $edits, $good, $images, $total, $users, + $activeUsers = 0; + + public function __construct() { + parent::__construct( 'Statistics' ); + } + + public function execute( $par ) { + $this->setHeaders(); + $this->getOutput()->addModuleStyles( 'mediawiki.special' ); + + $this->edits = SiteStats::edits(); + $this->good = SiteStats::articles(); + $this->images = SiteStats::images(); + $this->total = SiteStats::pages(); + $this->users = SiteStats::users(); + $this->activeUsers = SiteStats::activeUsers(); + $this->hook = ''; + + $text = Xml::openElement( 'table', [ 'class' => 'wikitable mw-statistics-table' ] ); + + # Statistic - pages + $text .= $this->getPageStats(); + + # Statistic - edits + $text .= $this->getEditStats(); + + # Statistic - users + $text .= $this->getUserStats(); + + # Statistic - usergroups + $text .= $this->getGroupStats(); + + # Statistic - other + $extraStats = []; + if ( Hooks::run( 'SpecialStatsAddExtra', [ &$extraStats, $this->getContext() ] ) ) { + $text .= $this->getOtherStats( $extraStats ); + } + + $text .= Xml::closeElement( 'table' ); + + # Customizable footer + $footer = $this->msg( 'statistics-footer' ); + if ( !$footer->isBlank() ) { + $text .= "\n" . $footer->parse(); + } + + $this->getOutput()->addHTML( $text ); + } + + /** + * Format a row + * @param string $text Description of the row + * @param float $number A statistical number + * @param array $trExtraParams Params to table row, see Html::elememt + * @param string $descMsg Message key + * @param array|string $descMsgParam Message parameters + * @return string Table row in HTML format + */ + private function formatRow( $text, $number, $trExtraParams = [], + $descMsg = '', $descMsgParam = '' + ) { + if ( $descMsg ) { + $msg = $this->msg( $descMsg, $descMsgParam ); + if ( !$msg->isDisabled() ) { + $descriptionHtml = $this->msg( 'parentheses' )->rawParams( $msg->parse() ) + ->escaped(); + $text .= "<br />" . Html::rawElement( + 'small', + [ 'class' => 'mw-statistic-desc' ], + " $descriptionHtml" + ); + } + } + + return Html::rawElement( 'tr', $trExtraParams, + Html::rawElement( 'td', [], $text ) . + Html::rawElement( 'td', [ 'class' => 'mw-statistics-numbers' ], $number ) + ); + } + + /** + * Each of these methods is pretty self-explanatory, get a particular + * row for the table of statistics + * @return string + */ + private function getPageStats() { + $linkRenderer = $this->getLinkRenderer(); + + $specialAllPagesTitle = SpecialPage::getTitleFor( 'Allpages' ); + $pageStatsHtml = Xml::openElement( 'tr' ) . + Xml::tags( 'th', [ 'colspan' => '2' ], $this->msg( 'statistics-header-pages' ) + ->parse() ) . + Xml::closeElement( 'tr' ) . + $this->formatRow( $linkRenderer->makeKnownLink( + $specialAllPagesTitle, + $this->msg( 'statistics-articles' )->text(), + [], [ 'hideredirects' => 1 ] ), + $this->getLanguage()->formatNum( $this->good ), + [ 'class' => 'mw-statistics-articles' ], + 'statistics-articles-desc' ) . + $this->formatRow( $linkRenderer->makeKnownLink( $specialAllPagesTitle, + $this->msg( 'statistics-pages' )->text() ), + $this->getLanguage()->formatNum( $this->total ), + [ 'class' => 'mw-statistics-pages' ], + 'statistics-pages-desc' ); + + // Show the image row only, when there are files or upload is possible + if ( $this->images !== 0 || $this->getConfig()->get( 'EnableUploads' ) ) { + $pageStatsHtml .= $this->formatRow( + $linkRenderer->makeKnownLink( SpecialPage::getTitleFor( 'MediaStatistics' ), + $this->msg( 'statistics-files' )->text() ), + $this->getLanguage()->formatNum( $this->images ), + [ 'class' => 'mw-statistics-files' ] ); + } + + return $pageStatsHtml; + } + + private function getEditStats() { + return Xml::openElement( 'tr' ) . + Xml::tags( 'th', [ 'colspan' => '2' ], + $this->msg( 'statistics-header-edits' )->parse() ) . + Xml::closeElement( 'tr' ) . + $this->formatRow( $this->msg( 'statistics-edits' )->parse(), + $this->getLanguage()->formatNum( $this->edits ), + [ 'class' => 'mw-statistics-edits' ] + ) . + $this->formatRow( $this->msg( 'statistics-edits-average' )->parse(), + $this->getLanguage()->formatNum( + sprintf( '%.2f', $this->total ? $this->edits / $this->total : 0 ) + ), [ 'class' => 'mw-statistics-edits-average' ] + ); + } + + private function getUserStats() { + return Xml::openElement( 'tr' ) . + Xml::tags( 'th', [ 'colspan' => '2' ], + $this->msg( 'statistics-header-users' )->parse() ) . + Xml::closeElement( 'tr' ) . + $this->formatRow( $this->msg( 'statistics-users' )->parse(), + $this->getLanguage()->formatNum( $this->users ), + [ 'class' => 'mw-statistics-users' ] + ) . + $this->formatRow( $this->msg( 'statistics-users-active' )->parse() . ' ' . + $this->getLinkRenderer()->makeKnownLink( + SpecialPage::getTitleFor( 'Activeusers' ), + $this->msg( 'listgrouprights-members' )->text() + ), + $this->getLanguage()->formatNum( $this->activeUsers ), + [ 'class' => 'mw-statistics-users-active' ], + 'statistics-users-active-desc', + $this->getLanguage()->formatNum( + $this->getConfig()->get( 'ActiveUserDays' ) ) + ); + } + + private function getGroupStats() { + $linkRenderer = $this->getLinkRenderer(); + $text = ''; + foreach ( $this->getConfig()->get( 'GroupPermissions' ) as $group => $permissions ) { + # Skip generic * and implicit groups + if ( in_array( $group, $this->getConfig()->get( 'ImplicitGroups' ) ) + || $group == '*' ) { + continue; + } + $groupname = htmlspecialchars( $group ); + $msg = $this->msg( 'group-' . $groupname ); + if ( $msg->isBlank() ) { + $groupnameLocalized = $groupname; + } else { + $groupnameLocalized = $msg->text(); + } + $msg = $this->msg( 'grouppage-' . $groupname )->inContentLanguage(); + if ( $msg->isBlank() ) { + $grouppageLocalized = MWNamespace::getCanonicalName( NS_PROJECT ) . + ':' . $groupname; + } else { + $grouppageLocalized = $msg->text(); + } + $linkTarget = Title::newFromText( $grouppageLocalized ); + + if ( $linkTarget ) { + $grouppage = $linkRenderer->makeLink( + $linkTarget, + $groupnameLocalized + ); + } else { + $grouppage = htmlspecialchars( $groupnameLocalized ); + } + + $grouplink = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Listusers' ), + $this->msg( 'listgrouprights-members' )->text(), + [], + [ 'group' => $group ] + ); + # Add a class when a usergroup contains no members to allow hiding these rows + $classZero = ''; + $countUsers = SiteStats::numberingroup( $groupname ); + if ( $countUsers == 0 ) { + $classZero = ' statistics-group-zero'; + } + $text .= $this->formatRow( $grouppage . ' ' . $grouplink, + $this->getLanguage()->formatNum( $countUsers ), + [ 'class' => 'statistics-group-' . Sanitizer::escapeClass( $group ) . + $classZero ] ); + } + + return $text; + } + + /** + * Conversion of external statistics into an internal representation + * Following a ([<header-message>][<item-message>] = number) pattern + * + * @param array $stats + * @return string + */ + private function getOtherStats( array $stats ) { + $return = ''; + + foreach ( $stats as $header => $items ) { + // Identify the structure used + if ( is_array( $items ) ) { + // Ignore headers that are recursively set as legacy header + if ( $header !== 'statistics-header-hooks' ) { + $return .= $this->formatRowHeader( $header ); + } + + // Collect all items that belong to the same header + foreach ( $items as $key => $value ) { + if ( is_array( $value ) ) { + $name = $value['name']; + $number = $value['number']; + } else { + $name = $this->msg( $key )->parse(); + $number = $value; + } + + $return .= $this->formatRow( + $name, + $this->getLanguage()->formatNum( htmlspecialchars( $number ) ), + [ 'class' => 'mw-statistics-hook', 'id' => 'mw-' . $key ] + ); + } + } else { + // Create the legacy header only once + if ( $return === '' ) { + $return .= $this->formatRowHeader( 'statistics-header-hooks' ); + } + + // Recursively remap the legacy structure + $return .= $this->getOtherStats( [ 'statistics-header-hooks' => + [ $header => $items ] ] ); + } + } + + return $return; + } + + /** + * Format row header + * + * @param string $header + * @return string + */ + private function formatRowHeader( $header ) { + return Xml::openElement( 'tr' ) . + Xml::tags( 'th', [ 'colspan' => '2' ], $this->msg( $header )->parse() ) . + Xml::closeElement( 'tr' ); + } + + protected function getGroupName() { + return 'wiki'; + } +} diff --git a/www/wiki/includes/specials/SpecialTags.php b/www/wiki/includes/specials/SpecialTags.php new file mode 100644 index 00000000..6b0598ce --- /dev/null +++ b/www/wiki/includes/specials/SpecialTags.php @@ -0,0 +1,482 @@ +<?php +/** + * Implements Special:Tags + * + * 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 + */ + +/** + * A special page that lists tags for edits + * + * @ingroup SpecialPage + */ +class SpecialTags extends SpecialPage { + + /** + * @var array List of explicitly defined tags + */ + protected $explicitlyDefinedTags; + + /** + * @var array List of software defined tags + */ + protected $softwareDefinedTags; + + /** + * @var array List of software activated tags + */ + protected $softwareActivatedTags; + + function __construct() { + parent::__construct( 'Tags' ); + } + + function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + + $request = $this->getRequest(); + switch ( $par ) { + case 'delete': + $this->showDeleteTagForm( $request->getVal( 'tag' ) ); + break; + case 'activate': + $this->showActivateDeactivateForm( $request->getVal( 'tag' ), true ); + break; + case 'deactivate': + $this->showActivateDeactivateForm( $request->getVal( 'tag' ), false ); + break; + case 'create': + // fall through, thanks to HTMLForm's logic + default: + $this->showTagList(); + break; + } + } + + function showTagList() { + $out = $this->getOutput(); + $out->setPageTitle( $this->msg( 'tags-title' ) ); + $out->wrapWikiMsg( "<div class='mw-tags-intro'>\n$1\n</div>", 'tags-intro' ); + + $user = $this->getUser(); + $userCanManage = $user->isAllowed( 'managechangetags' ); + $userCanDelete = $user->isAllowed( 'deletechangetags' ); + $userCanEditInterface = $user->isAllowed( 'editinterface' ); + + // Show form to create a tag + if ( $userCanManage ) { + $fields = [ + 'Tag' => [ + 'type' => 'text', + 'label' => $this->msg( 'tags-create-tag-name' )->plain(), + 'required' => true, + ], + 'Reason' => [ + 'type' => 'text', + 'label' => $this->msg( 'tags-create-reason' )->plain(), + 'size' => 50, + ], + 'IgnoreWarnings' => [ + 'type' => 'hidden', + ], + ]; + + $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() ); + $form->setAction( $this->getPageTitle( 'create' )->getLocalURL() ); + $form->setWrapperLegendMsg( 'tags-create-heading' ); + $form->setHeaderText( $this->msg( 'tags-create-explanation' )->parseAsBlock() ); + $form->setSubmitCallback( [ $this, 'processCreateTagForm' ] ); + $form->setSubmitTextMsg( 'tags-create-submit' ); + $form->show(); + + // If processCreateTagForm generated a redirect, there's no point + // continuing with this, as the user is just going to end up getting sent + // somewhere else. Additionally, if we keep going here, we end up + // populating the memcache of tag data (see ChangeTags::listDefinedTags) + // with out-of-date data from the replica DB, because the replica DB hasn't caught + // up to the fact that a new tag has been created as part of an implicit, + // as yet uncommitted transaction on master. + if ( $out->getRedirect() !== '' ) { + return; + } + } + + // Used to get hitcounts for #doTagRow() + $tagStats = ChangeTags::tagUsageStatistics(); + + // Used in #doTagRow() + $this->explicitlyDefinedTags = array_fill_keys( + ChangeTags::listExplicitlyDefinedTags(), true ); + $this->softwareDefinedTags = array_fill_keys( + ChangeTags::listSoftwareDefinedTags(), true ); + + // List all defined tags, even if they were never applied + $definedTags = array_keys( $this->explicitlyDefinedTags + $this->softwareDefinedTags ); + + // Show header only if there exists atleast one tag + if ( !$tagStats && !$definedTags ) { + return; + } + + // Write the headers + $html = Xml::tags( 'tr', null, Xml::tags( 'th', null, $this->msg( 'tags-tag' )->parse() ) . + Xml::tags( 'th', null, $this->msg( 'tags-display-header' )->parse() ) . + Xml::tags( 'th', null, $this->msg( 'tags-description-header' )->parse() ) . + Xml::tags( 'th', null, $this->msg( 'tags-source-header' )->parse() ) . + Xml::tags( 'th', null, $this->msg( 'tags-active-header' )->parse() ) . + Xml::tags( 'th', null, $this->msg( 'tags-hitcount-header' )->parse() ) . + ( ( $userCanManage || $userCanDelete ) ? + Xml::tags( 'th', [ 'class' => 'unsortable' ], + $this->msg( 'tags-actions-header' )->parse() ) : + '' ) + ); + + // Used in #doTagRow() + $this->softwareActivatedTags = array_fill_keys( + ChangeTags::listSoftwareActivatedTags(), true ); + + // Insert tags that have been applied at least once + foreach ( $tagStats as $tag => $hitcount ) { + $html .= $this->doTagRow( $tag, $hitcount, $userCanManage, + $userCanDelete, $userCanEditInterface ); + } + // Insert tags defined somewhere but never applied + foreach ( $definedTags as $tag ) { + if ( !isset( $tagStats[$tag] ) ) { + $html .= $this->doTagRow( $tag, 0, $userCanManage, $userCanDelete, $userCanEditInterface ); + } + } + + $out->addHTML( Xml::tags( + 'table', + [ 'class' => 'mw-datatable sortable mw-tags-table' ], + $html + ) ); + } + + function doTagRow( $tag, $hitcount, $showManageActions, $showDeleteActions, $showEditLinks ) { + $newRow = ''; + $newRow .= Xml::tags( 'td', null, Xml::element( 'code', null, $tag ) ); + + $linkRenderer = $this->getLinkRenderer(); + $disp = ChangeTags::tagDescription( $tag, $this->getContext() ); + if ( $showEditLinks ) { + $disp .= ' '; + $editLink = $linkRenderer->makeLink( + $this->msg( "tag-$tag" )->inContentLanguage()->getTitle(), + $this->msg( 'tags-edit' )->text() + ); + $disp .= $this->msg( 'parentheses' )->rawParams( $editLink )->escaped(); + } + $newRow .= Xml::tags( 'td', null, $disp ); + + $msg = $this->msg( "tag-$tag-description" ); + $desc = !$msg->exists() ? '' : $msg->parse(); + if ( $showEditLinks ) { + $desc .= ' '; + $editDescLink = $linkRenderer->makeLink( + $this->msg( "tag-$tag-description" )->inContentLanguage()->getTitle(), + $this->msg( 'tags-edit' )->text() + ); + $desc .= $this->msg( 'parentheses' )->rawParams( $editDescLink )->escaped(); + } + $newRow .= Xml::tags( 'td', null, $desc ); + + $sourceMsgs = []; + $isSoftware = isset( $this->softwareDefinedTags[$tag] ); + $isExplicit = isset( $this->explicitlyDefinedTags[$tag] ); + if ( $isSoftware ) { + // TODO: Rename this message + $sourceMsgs[] = $this->msg( 'tags-source-extension' )->escaped(); + } + if ( $isExplicit ) { + $sourceMsgs[] = $this->msg( 'tags-source-manual' )->escaped(); + } + if ( !$sourceMsgs ) { + $sourceMsgs[] = $this->msg( 'tags-source-none' )->escaped(); + } + $newRow .= Xml::tags( 'td', null, implode( Xml::element( 'br' ), $sourceMsgs ) ); + + $isActive = $isExplicit || isset( $this->softwareActivatedTags[$tag] ); + $activeMsg = ( $isActive ? 'tags-active-yes' : 'tags-active-no' ); + $newRow .= Xml::tags( 'td', null, $this->msg( $activeMsg )->escaped() ); + + $hitcountLabelMsg = $this->msg( 'tags-hitcount' )->numParams( $hitcount ); + if ( $this->getConfig()->get( 'UseTagFilter' ) ) { + $hitcountLabel = $linkRenderer->makeLink( + SpecialPage::getTitleFor( 'Recentchanges' ), + $hitcountLabelMsg->text(), + [], + [ 'tagfilter' => $tag ] + ); + } else { + $hitcountLabel = $hitcountLabelMsg->escaped(); + } + + // add raw $hitcount for sorting, because tags-hitcount contains numbers and letters + $newRow .= Xml::tags( 'td', [ 'data-sort-value' => $hitcount ], $hitcountLabel ); + + // actions + $actionLinks = []; + + // delete + if ( $showDeleteActions && ChangeTags::canDeleteTag( $tag )->isOK() ) { + $actionLinks[] = $linkRenderer->makeKnownLink( + $this->getPageTitle( 'delete' ), + $this->msg( 'tags-delete' )->text(), + [], + [ 'tag' => $tag ] ); + } + + if ( $showManageActions ) { // we've already checked that the user had the requisite userright + // activate + if ( ChangeTags::canActivateTag( $tag )->isOK() ) { + $actionLinks[] = $linkRenderer->makeKnownLink( + $this->getPageTitle( 'activate' ), + $this->msg( 'tags-activate' )->text(), + [], + [ 'tag' => $tag ] ); + } + + // deactivate + if ( ChangeTags::canDeactivateTag( $tag )->isOK() ) { + $actionLinks[] = $linkRenderer->makeKnownLink( + $this->getPageTitle( 'deactivate' ), + $this->msg( 'tags-deactivate' )->text(), + [], + [ 'tag' => $tag ] ); + } + } + + if ( $showDeleteActions || $showManageActions ) { + $newRow .= Xml::tags( 'td', null, $this->getLanguage()->pipeList( $actionLinks ) ); + } + + return Xml::tags( 'tr', null, $newRow ) . "\n"; + } + + public function processCreateTagForm( array $data, HTMLForm $form ) { + $context = $form->getContext(); + $out = $context->getOutput(); + + $tag = trim( strval( $data['Tag'] ) ); + $ignoreWarnings = isset( $data['IgnoreWarnings'] ) && $data['IgnoreWarnings'] === '1'; + $status = ChangeTags::createTagWithChecks( $tag, $data['Reason'], + $context->getUser(), $ignoreWarnings ); + + if ( $status->isGood() ) { + $out->redirect( $this->getPageTitle()->getLocalURL() ); + return true; + } elseif ( $status->isOK() ) { + // we have some warnings, so we show a confirmation form + $fields = [ + 'Tag' => [ + 'type' => 'hidden', + 'default' => $data['Tag'], + ], + 'Reason' => [ + 'type' => 'hidden', + 'default' => $data['Reason'], + ], + 'IgnoreWarnings' => [ + 'type' => 'hidden', + 'default' => '1', + ], + ]; + + // fool HTMLForm into thinking the form hasn't been submitted yet. Otherwise + // we get into an infinite loop! + $context->getRequest()->unsetVal( 'wpEditToken' ); + + $headerText = $this->msg( 'tags-create-warnings-above', $tag, + count( $status->getWarningsArray() ) )->parseAsBlock() . + $out->parse( $status->getWikiText() ) . + $this->msg( 'tags-create-warnings-below' )->parseAsBlock(); + + $subform = HTMLForm::factory( 'ooui', $fields, $this->getContext() ); + $subform->setAction( $this->getPageTitle( 'create' )->getLocalURL() ); + $subform->setWrapperLegendMsg( 'tags-create-heading' ); + $subform->setHeaderText( $headerText ); + $subform->setSubmitCallback( [ $this, 'processCreateTagForm' ] ); + $subform->setSubmitTextMsg( 'htmlform-yes' ); + $subform->show(); + + $out->addBacklinkSubtitle( $this->getPageTitle() ); + return true; + } else { + $out->addWikiText( "<div class=\"error\">\n" . $status->getWikiText() . + "\n</div>" ); + return false; + } + } + + protected function showDeleteTagForm( $tag ) { + $user = $this->getUser(); + if ( !$user->isAllowed( 'deletechangetags' ) ) { + throw new PermissionsError( 'deletechangetags' ); + } + + $out = $this->getOutput(); + $out->setPageTitle( $this->msg( 'tags-delete-title' ) ); + $out->addBacklinkSubtitle( $this->getPageTitle() ); + + // is the tag actually able to be deleted? + $canDeleteResult = ChangeTags::canDeleteTag( $tag, $user ); + if ( !$canDeleteResult->isGood() ) { + $out->addWikiText( "<div class=\"error\">\n" . $canDeleteResult->getWikiText() . + "\n</div>" ); + if ( !$canDeleteResult->isOK() ) { + return; + } + } + + $preText = $this->msg( 'tags-delete-explanation-initial', $tag )->parseAsBlock(); + $tagUsage = ChangeTags::tagUsageStatistics(); + if ( isset( $tagUsage[$tag] ) && $tagUsage[$tag] > 0 ) { + $preText .= $this->msg( 'tags-delete-explanation-in-use', $tag, + $tagUsage[$tag] )->parseAsBlock(); + } + $preText .= $this->msg( 'tags-delete-explanation-warning', $tag )->parseAsBlock(); + + // see if the tag is in use + $this->softwareActivatedTags = array_fill_keys( + ChangeTags::listSoftwareActivatedTags(), true ); + if ( isset( $this->softwareActivatedTags[$tag] ) ) { + $preText .= $this->msg( 'tags-delete-explanation-active', $tag )->parseAsBlock(); + } + + $fields = []; + $fields['Reason'] = [ + 'type' => 'text', + 'label' => $this->msg( 'tags-delete-reason' )->plain(), + 'size' => 50, + ]; + $fields['HiddenTag'] = [ + 'type' => 'hidden', + 'name' => 'tag', + 'default' => $tag, + 'required' => true, + ]; + + $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() ); + $form->setAction( $this->getPageTitle( 'delete' )->getLocalURL() ); + $form->tagAction = 'delete'; // custom property on HTMLForm object + $form->setSubmitCallback( [ $this, 'processTagForm' ] ); + $form->setSubmitTextMsg( 'tags-delete-submit' ); + $form->setSubmitDestructive(); // nasty! + $form->addPreText( $preText ); + $form->show(); + } + + protected function showActivateDeactivateForm( $tag, $activate ) { + $actionStr = $activate ? 'activate' : 'deactivate'; + + $user = $this->getUser(); + if ( !$user->isAllowed( 'managechangetags' ) ) { + throw new PermissionsError( 'managechangetags' ); + } + + $out = $this->getOutput(); + // tags-activate-title, tags-deactivate-title + $out->setPageTitle( $this->msg( "tags-$actionStr-title" ) ); + $out->addBacklinkSubtitle( $this->getPageTitle() ); + + // is it possible to do this? + $func = $activate ? 'canActivateTag' : 'canDeactivateTag'; + $result = ChangeTags::$func( $tag, $user ); + if ( !$result->isGood() ) { + $out->addWikiText( "<div class=\"error\">\n" . $result->getWikiText() . + "\n</div>" ); + if ( !$result->isOK() ) { + return; + } + } + + // tags-activate-question, tags-deactivate-question + $preText = $this->msg( "tags-$actionStr-question", $tag )->parseAsBlock(); + + $fields = []; + // tags-activate-reason, tags-deactivate-reason + $fields['Reason'] = [ + 'type' => 'text', + 'label' => $this->msg( "tags-$actionStr-reason" )->plain(), + 'size' => 50, + ]; + $fields['HiddenTag'] = [ + 'type' => 'hidden', + 'name' => 'tag', + 'default' => $tag, + 'required' => true, + ]; + + $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() ); + $form->setAction( $this->getPageTitle( $actionStr )->getLocalURL() ); + $form->tagAction = $actionStr; + $form->setSubmitCallback( [ $this, 'processTagForm' ] ); + // tags-activate-submit, tags-deactivate-submit + $form->setSubmitTextMsg( "tags-$actionStr-submit" ); + $form->addPreText( $preText ); + $form->show(); + } + + public function processTagForm( array $data, HTMLForm $form ) { + $context = $form->getContext(); + $out = $context->getOutput(); + + $tag = $data['HiddenTag']; + $status = call_user_func( [ ChangeTags::class, "{$form->tagAction}TagWithChecks" ], + $tag, $data['Reason'], $context->getUser(), true ); + + if ( $status->isGood() ) { + $out->redirect( $this->getPageTitle()->getLocalURL() ); + return true; + } elseif ( $status->isOK() && $form->tagAction === 'delete' ) { + // deletion succeeded, but hooks raised a warning + $out->addWikiText( $this->msg( 'tags-delete-warnings-after-delete', $tag, + count( $status->getWarningsArray() ) )->text() . "\n" . + $status->getWikitext() ); + $out->addReturnTo( $this->getPageTitle() ); + return true; + } else { + $out->addWikiText( "<div class=\"error\">\n" . $status->getWikitext() . + "\n</div>" ); + return false; + } + } + + /** + * Return an array of subpages that this special page will accept. + * + * @return string[] subpages + */ + public function getSubpagesForPrefixSearch() { + // The subpages does not have an own form, so not listing it at the moment + return [ + // 'delete', + // 'activate', + // 'deactivate', + // 'create', + ]; + } + + protected function getGroupName() { + return 'changes'; + } +} diff --git a/www/wiki/includes/specials/SpecialTrackingCategories.php b/www/wiki/includes/specials/SpecialTrackingCategories.php new file mode 100644 index 00000000..e503d92b --- /dev/null +++ b/www/wiki/includes/specials/SpecialTrackingCategories.php @@ -0,0 +1,130 @@ +<?php +/** + * Implements Special:TrackingCategories + * + * 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 + */ + +/** + * A special page that displays list of tracking categories + * Tracking categories allow pages with certain characteristics to be tracked. + * It works by adding any such page to a category automatically. + * Category is specified by the tracking category's system message. + * + * @ingroup SpecialPage + * @since 1.23 + */ + +class SpecialTrackingCategories extends SpecialPage { + function __construct() { + parent::__construct( 'TrackingCategories' ); + } + + function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + $this->getOutput()->allowClickjacking(); + $this->getOutput()->addHTML( + Html::openElement( 'table', [ 'class' => 'mw-datatable', + 'id' => 'mw-trackingcategories-table' ] ) . "\n" . + "<thead><tr> + <th>" . + $this->msg( 'trackingcategories-msg' )->escaped() . " + </th> + <th>" . + $this->msg( 'trackingcategories-name' )->escaped() . + "</th> + <th>" . + $this->msg( 'trackingcategories-desc' )->escaped() . " + </th> + </tr></thead>" + ); + + $trackingCategories = new TrackingCategories( $this->getConfig() ); + $categoryList = $trackingCategories->getTrackingCategories(); + + $batch = new LinkBatch(); + foreach ( $categoryList as $catMsg => $data ) { + $batch->addObj( $data['msg'] ); + foreach ( $data['cats'] as $catTitle ) { + $batch->addObj( $catTitle ); + } + } + $batch->execute(); + + Hooks::run( 'SpecialTrackingCategories::preprocess', [ $this, $categoryList ] ); + + $linkRenderer = $this->getLinkRenderer(); + + foreach ( $categoryList as $catMsg => $data ) { + $allMsgs = []; + $catDesc = $catMsg . '-desc'; + + $catMsgTitleText = $linkRenderer->makeLink( + $data['msg'], + $catMsg + ); + + foreach ( $data['cats'] as $catTitle ) { + $html = $linkRenderer->makeLink( + $catTitle, + $catTitle->getText() + ); + + Hooks::run( 'SpecialTrackingCategories::generateCatLink', + [ $this, $catTitle, &$html ] ); + + $allMsgs[] = $html; + } + + # Extra message, when no category was found + if ( !count( $allMsgs ) ) { + $allMsgs[] = $this->msg( 'trackingcategories-disabled' )->parse(); + } + + /* + * Show category description if it exists as a system message + * as category-name-desc + */ + $descMsg = $this->msg( $catDesc ); + if ( $descMsg->isBlank() ) { + $descMsg = $this->msg( 'trackingcategories-nodesc' ); + } + + $this->getOutput()->addHTML( + Html::openElement( 'tr' ) . + Html::openElement( 'td', [ 'class' => 'mw-trackingcategories-name' ] ) . + $this->getLanguage()->commaList( array_unique( $allMsgs ) ) . + Html::closeElement( 'td' ) . + Html::openElement( 'td', [ 'class' => 'mw-trackingcategories-msg' ] ) . + $catMsgTitleText . + Html::closeElement( 'td' ) . + Html::openElement( 'td', [ 'class' => 'mw-trackingcategories-desc' ] ) . + $descMsg->parse() . + Html::closeElement( 'td' ) . + Html::closeElement( 'tr' ) + ); + } + $this->getOutput()->addHTML( Html::closeElement( 'table' ) ); + } + + protected function getGroupName() { + return 'pages'; + } +} diff --git a/www/wiki/includes/specials/SpecialUnblock.php b/www/wiki/includes/specials/SpecialUnblock.php new file mode 100644 index 00000000..b2d5a163 --- /dev/null +++ b/www/wiki/includes/specials/SpecialUnblock.php @@ -0,0 +1,278 @@ +<?php +/** + * Implements Special:Unblock + * + * 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 + */ + +/** + * A special page for unblocking users + * + * @ingroup SpecialPage + */ +class SpecialUnblock extends SpecialPage { + + protected $target; + protected $type; + protected $block; + + public function __construct() { + parent::__construct( 'Unblock', 'block' ); + } + + public function doesWrites() { + return true; + } + + public function execute( $par ) { + $this->checkPermissions(); + $this->checkReadOnly(); + + list( $this->target, $this->type ) = SpecialBlock::getTargetAndType( $par, $this->getRequest() ); + $this->block = Block::newFromTarget( $this->target ); + if ( $this->target instanceof User ) { + # Set the 'relevant user' in the skin, so it displays links like Contributions, + # User logs, UserRights, etc. + $this->getSkin()->setRelevantUser( $this->target ); + } + + $this->setHeaders(); + $this->outputHeader(); + + $out = $this->getOutput(); + $out->setPageTitle( $this->msg( 'unblockip' ) ); + $out->addModules( [ 'mediawiki.userSuggest' ] ); + + $form = HTMLForm::factory( 'ooui', $this->getFields(), $this->getContext() ); + $form->setWrapperLegendMsg( 'unblockip' ); + $form->setSubmitCallback( [ __CLASS__, 'processUIUnblock' ] ); + $form->setSubmitTextMsg( 'ipusubmit' ); + $form->addPreText( $this->msg( 'unblockiptext' )->parseAsBlock() ); + + if ( $form->show() ) { + switch ( $this->type ) { + case Block::TYPE_IP: + $out->addWikiMsg( 'unblocked-ip', wfEscapeWikiText( $this->target ) ); + break; + case Block::TYPE_USER: + $out->addWikiMsg( 'unblocked', wfEscapeWikiText( $this->target ) ); + break; + case Block::TYPE_RANGE: + $out->addWikiMsg( 'unblocked-range', wfEscapeWikiText( $this->target ) ); + break; + case Block::TYPE_ID: + case Block::TYPE_AUTO: + $out->addWikiMsg( 'unblocked-id', wfEscapeWikiText( $this->target ) ); + break; + } + } + } + + protected function getFields() { + $fields = [ + 'Target' => [ + 'type' => 'text', + 'label-message' => 'ipaddressorusername', + 'autofocus' => true, + 'size' => '45', + 'required' => true, + 'cssclass' => 'mw-autocomplete-user', // used by mediawiki.userSuggest + ], + 'Name' => [ + 'type' => 'info', + 'label-message' => 'ipaddressorusername', + ], + 'Reason' => [ + 'type' => 'text', + 'label-message' => 'ipbreason', + ] + ]; + + if ( $this->block instanceof Block ) { + list( $target, $type ) = $this->block->getTargetAndType(); + + # Autoblocks are logged as "autoblock #123 because the IP was recently used by + # User:Foo, and we've just got any block, auto or not, that applies to a target + # the user has specified. Someone could be fishing to connect IPs to autoblocks, + # so don't show any distinction between unblocked IPs and autoblocked IPs + if ( $type == Block::TYPE_AUTO && $this->type == Block::TYPE_IP ) { + $fields['Target']['default'] = $this->target; + unset( $fields['Name'] ); + } else { + $fields['Target']['default'] = $target; + $fields['Target']['type'] = 'hidden'; + switch ( $type ) { + case Block::TYPE_IP: + $fields['Name']['default'] = $this->getLinkRenderer()->makeKnownLink( + SpecialPage::getTitleFor( 'Contributions', $target->getName() ), + $target->getName() + ); + $fields['Name']['raw'] = true; + break; + case Block::TYPE_USER: + $fields['Name']['default'] = $this->getLinkRenderer()->makeLink( + $target->getUserPage(), + $target->getName() + ); + $fields['Name']['raw'] = true; + break; + + case Block::TYPE_RANGE: + $fields['Name']['default'] = $target; + break; + + case Block::TYPE_AUTO: + $fields['Name']['default'] = $this->block->getRedactedName(); + $fields['Name']['raw'] = true; + # Don't expose the real target of the autoblock + $fields['Target']['default'] = "#{$this->target}"; + break; + } + // target is hidden, so the reason is the first element + $fields['Target']['autofocus'] = false; + $fields['Reason']['autofocus'] = true; + } + } else { + $fields['Target']['default'] = $this->target; + unset( $fields['Name'] ); + } + + return $fields; + } + + /** + * Submit callback for an HTMLForm object + * @param array $data + * @param HTMLForm $form + * @return array|bool Array(message key, parameters) + */ + public static function processUIUnblock( array $data, HTMLForm $form ) { + return self::processUnblock( $data, $form->getContext() ); + } + + /** + * Process the form + * + * Change tags can be provided via $data['Tags'], but the calling function + * must check if the tags can be added by the user prior to this function. + * + * @param array $data + * @param IContextSource $context + * @throws ErrorPageError + * @return array|bool Array( Array( message key, parameters ) ) on failure, True on success + */ + public static function processUnblock( array $data, IContextSource $context ) { + $performer = $context->getUser(); + $target = $data['Target']; + $block = Block::newFromTarget( $data['Target'] ); + + if ( !$block instanceof Block ) { + return [ [ 'ipb_cant_unblock', $target ] ]; + } + + # T17810: blocked admins should have limited access here. This + # won't allow sysops to remove autoblocks on themselves, but they + # should have ipblock-exempt anyway + $status = SpecialBlock::checkUnblockSelf( $target, $performer ); + if ( $status !== true ) { + throw new ErrorPageError( 'badaccess', $status ); + } + + # If the specified IP is a single address, and the block is a range block, don't + # unblock the whole range. + list( $target, $type ) = SpecialBlock::getTargetAndType( $target ); + if ( $block->getType() == Block::TYPE_RANGE && $type == Block::TYPE_IP ) { + $range = $block->getTarget(); + + return [ [ 'ipb_blocked_as_range', $target, $range ] ]; + } + + # If the name was hidden and the blocking user cannot hide + # names, then don't allow any block removals... + if ( !$performer->isAllowed( 'hideuser' ) && $block->mHideName ) { + return [ 'unblock-hideuser' ]; + } + + $reason = [ 'hookaborted' ]; + if ( !Hooks::run( 'UnblockUser', [ &$block, &$performer, &$reason ] ) ) { + return $reason; + } + + # Delete block + if ( !$block->delete() ) { + return [ [ 'ipb_cant_unblock', htmlspecialchars( $block->getTarget() ) ] ]; + } + + Hooks::run( 'UnblockUserComplete', [ $block, $performer ] ); + + # Unset _deleted fields as needed + if ( $block->mHideName ) { + # Something is deeply FUBAR if this is not a User object, but who knows? + $id = $block->getTarget() instanceof User + ? $block->getTarget()->getId() + : User::idFromName( $block->getTarget() ); + + RevisionDeleteUser::unsuppressUserName( $block->getTarget(), $id ); + } + + # Redact the name (IP address) for autoblocks + if ( $block->getType() == Block::TYPE_AUTO ) { + $page = Title::makeTitle( NS_USER, '#' . $block->getId() ); + } else { + $page = $block->getTarget() instanceof User + ? $block->getTarget()->getUserPage() + : Title::makeTitle( NS_USER, $block->getTarget() ); + } + + # Make log entry + $logEntry = new ManualLogEntry( 'block', 'unblock' ); + $logEntry->setTarget( $page ); + $logEntry->setComment( $data['Reason'] ); + $logEntry->setPerformer( $performer ); + if ( isset( $data['Tags'] ) ) { + $logEntry->setTags( $data['Tags'] ); + } + $logId = $logEntry->insert(); + $logEntry->publish( $logId ); + + return true; + } + + /** + * 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'; + } +} diff --git a/www/wiki/includes/specials/SpecialUncategorizedcategories.php b/www/wiki/includes/specials/SpecialUncategorizedcategories.php new file mode 100644 index 00000000..2dcb77f8 --- /dev/null +++ b/www/wiki/includes/specials/SpecialUncategorizedcategories.php @@ -0,0 +1,93 @@ +<?php +/** + * Implements Special:Uncategorizedcategories + * + * 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 + */ + +/** + * A special page that lists uncategorized categories + * + * @ingroup SpecialPage + */ +class UncategorizedCategoriesPage extends UncategorizedPagesPage { + /** + * Holds a list of categories, which shouldn't be listed on this special page, + * even if it is uncategorized. + * @var array + */ + private $exceptionList = null; + + function __construct( $name = 'Uncategorizedcategories' ) { + parent::__construct( $name ); + $this->requestedNamespace = NS_CATEGORY; + } + + /** + * Returns an array of category titles (usually without the namespace), which + * shouldn't be listed on this page, even if they're uncategorized. + * + * @return array + */ + private function getExceptionList() { + if ( $this->exceptionList === null ) { + $exList = $this->msg( 'uncategorized-categories-exceptionlist' ) + ->inContentLanguage()->plain(); + $proposedTitles = explode( "\n", $exList ); + foreach ( $proposedTitles as $count => $titleStr ) { + if ( strpos( $titleStr, '*' ) !== 0 ) { + continue; + } + $titleStr = preg_replace( "/^\\*\\s*/", '', $titleStr ); + $title = Title::newFromText( $titleStr, NS_CATEGORY ); + if ( $title && $title->getNamespace() !== NS_CATEGORY ) { + $title = Title::makeTitleSafe( NS_CATEGORY, $titleStr ); + } + if ( $title ) { + $this->exceptionList[] = $title->getDBkey(); + } + } + } + return $this->exceptionList; + } + + public function getQueryInfo() { + $dbr = wfGetDB( DB_REPLICA ); + $query = parent::getQueryInfo(); + $exceptionList = $this->getExceptionList(); + if ( $exceptionList ) { + $query['conds'][] = 'page_title not in ( ' . $dbr->makeList( $exceptionList ) . ' )'; + } + + return $query; + } + + /** + * Formats the result + * @param Skin $skin The current skin + * @param object $result The query result + * @return string The category link + */ + function formatResult( $skin, $result ) { + $title = Title::makeTitle( NS_CATEGORY, $result->title ); + $text = $title->getText(); + + return $this->getLinkRenderer()->makeKnownLink( $title, $text ); + } +} diff --git a/www/wiki/includes/specials/SpecialUncategorizedimages.php b/www/wiki/includes/specials/SpecialUncategorizedimages.php new file mode 100644 index 00000000..1cb27a3f --- /dev/null +++ b/www/wiki/includes/specials/SpecialUncategorizedimages.php @@ -0,0 +1,65 @@ +<?php +/** + * Implements Special:Uncategorizedimages + * + * 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 + * @author Rob Church <robchur@gmail.com> + */ + +/** + * Special page lists images which haven't been categorised + * + * @ingroup SpecialPage + * @todo FIXME: Use an instance of UncategorizedPagesPage or something + */ +class UncategorizedImagesPage extends ImageQueryPage { + function __construct( $name = 'Uncategorizedimages' ) { + parent::__construct( $name ); + } + + function sortDescending() { + return false; + } + + function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + function getQueryInfo() { + return [ + 'tables' => [ 'page', 'categorylinks' ], + 'fields' => [ 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'page_title' ], + 'conds' => [ 'cl_from IS NULL', + 'page_namespace' => NS_FILE, + 'page_is_redirect' => 0 ], + 'join_conds' => [ 'categorylinks' => [ + 'LEFT JOIN', 'cl_from=page_id' ] ] + ]; + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialUncategorizedpages.php b/www/wiki/includes/specials/SpecialUncategorizedpages.php new file mode 100644 index 00000000..30b33cc6 --- /dev/null +++ b/www/wiki/includes/specials/SpecialUncategorizedpages.php @@ -0,0 +1,85 @@ +<?php +/** + * Implements Special:Uncategorizedpages + * + * 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 + */ + +/** + * A special page looking for page without any category. + * + * @ingroup SpecialPage + * @todo FIXME: Make $requestedNamespace selectable, unify all subclasses into one + */ +class UncategorizedPagesPage extends PageQueryPage { + protected $requestedNamespace = false; + + function __construct( $name = 'Uncategorizedpages' ) { + parent::__construct( $name ); + } + + function sortDescending() { + return false; + } + + function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + function getQueryInfo() { + return [ + 'tables' => [ 'page', 'categorylinks' ], + 'fields' => [ + 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'page_title' + ], + // default for page_namespace is all content namespaces (if requestedNamespace is false) + // otherwise, page_namespace is requestedNamespace + 'conds' => [ + 'cl_from IS NULL', + 'page_namespace' => $this->requestedNamespace !== false + ? $this->requestedNamespace + : MWNamespace::getContentNamespaces(), + 'page_is_redirect' => 0 + ], + 'join_conds' => [ + 'categorylinks' => [ 'LEFT JOIN', 'cl_from = page_id' ] + ] + ]; + } + + function getOrderFields() { + // For some crazy reason ordering by a constant + // causes a filesort + if ( $this->requestedNamespace === false && count( MWNamespace::getContentNamespaces() ) > 1 ) { + return [ 'page_namespace', 'page_title' ]; + } + + return [ 'page_title' ]; + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialUncategorizedtemplates.php b/www/wiki/includes/specials/SpecialUncategorizedtemplates.php new file mode 100644 index 00000000..af038fa8 --- /dev/null +++ b/www/wiki/includes/specials/SpecialUncategorizedtemplates.php @@ -0,0 +1,36 @@ +<?php +/** + * Implements Special:Uncategorizedtemplates + * + * 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 + * @author Rob Church <robchur@gmail.com> + */ + +/** + * Special page lists all uncategorised pages in the + * template namespace + * + * @ingroup SpecialPage + */ +class UncategorizedTemplatesPage extends UncategorizedPagesPage { + public function __construct( $name = 'Uncategorizedtemplates' ) { + parent::__construct( $name ); + $this->requestedNamespace = NS_TEMPLATE; + } +} diff --git a/www/wiki/includes/specials/SpecialUndelete.php b/www/wiki/includes/specials/SpecialUndelete.php new file mode 100644 index 00000000..540dbc6b --- /dev/null +++ b/www/wiki/includes/specials/SpecialUndelete.php @@ -0,0 +1,1200 @@ +<?php +/** + * Implements Special:Undelete + * + * 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 + */ + +use Wikimedia\Rdbms\IResultWrapper; + +/** + * Special page allowing users with the appropriate permissions to view + * and restore deleted content. + * + * @ingroup SpecialPage + */ +class SpecialUndelete extends SpecialPage { + private $mAction; + private $mTarget; + private $mTimestamp; + private $mRestore; + private $mRevdel; + private $mInvert; + private $mFilename; + private $mTargetTimestamp; + private $mAllowed; + private $mCanView; + private $mComment; + private $mToken; + + /** @var Title */ + private $mTargetObj; + /** + * @var string Search prefix + */ + private $mSearchPrefix; + + function __construct() { + parent::__construct( 'Undelete', 'deletedhistory' ); + } + + public function doesWrites() { + return true; + } + + function loadRequest( $par ) { + $request = $this->getRequest(); + $user = $this->getUser(); + + $this->mAction = $request->getVal( 'action' ); + if ( $par !== null && $par !== '' ) { + $this->mTarget = $par; + } else { + $this->mTarget = $request->getVal( 'target' ); + } + + $this->mTargetObj = null; + + if ( $this->mTarget !== null && $this->mTarget !== '' ) { + $this->mTargetObj = Title::newFromText( $this->mTarget ); + } + + $this->mSearchPrefix = $request->getText( 'prefix' ); + $time = $request->getVal( 'timestamp' ); + $this->mTimestamp = $time ? wfTimestamp( TS_MW, $time ) : ''; + $this->mFilename = $request->getVal( 'file' ); + + $posted = $request->wasPosted() && + $user->matchEditToken( $request->getVal( 'wpEditToken' ) ); + $this->mRestore = $request->getCheck( 'restore' ) && $posted; + $this->mRevdel = $request->getCheck( 'revdel' ) && $posted; + $this->mInvert = $request->getCheck( 'invert' ) && $posted; + $this->mPreview = $request->getCheck( 'preview' ) && $posted; + $this->mDiff = $request->getCheck( 'diff' ); + $this->mDiffOnly = $request->getBool( 'diffonly', $this->getUser()->getOption( 'diffonly' ) ); + $this->mComment = $request->getText( 'wpComment' ); + $this->mUnsuppress = $request->getVal( 'wpUnsuppress' ) && $user->isAllowed( 'suppressrevision' ); + $this->mToken = $request->getVal( 'token' ); + + if ( $this->isAllowed( 'undelete' ) && !$user->isBlocked() ) { + $this->mAllowed = true; // user can restore + $this->mCanView = true; // user can view content + } elseif ( $this->isAllowed( 'deletedtext' ) ) { + $this->mAllowed = false; // user cannot restore + $this->mCanView = true; // user can view content + $this->mRestore = false; + } else { // user can only view the list of revisions + $this->mAllowed = false; + $this->mCanView = false; + $this->mTimestamp = ''; + $this->mRestore = false; + } + + if ( $this->mRestore || $this->mInvert ) { + $timestamps = []; + $this->mFileVersions = []; + foreach ( $request->getValues() as $key => $val ) { + $matches = []; + if ( preg_match( '/^ts(\d{14})$/', $key, $matches ) ) { + array_push( $timestamps, $matches[1] ); + } + + if ( preg_match( '/^fileid(\d+)$/', $key, $matches ) ) { + $this->mFileVersions[] = intval( $matches[1] ); + } + } + rsort( $timestamps ); + $this->mTargetTimestamp = $timestamps; + } + } + + /** + * Checks whether a user is allowed the permission for the + * specific title if one is set. + * + * @param string $permission + * @param User $user + * @return bool + */ + protected function isAllowed( $permission, User $user = null ) { + $user = $user ?: $this->getUser(); + if ( $this->mTargetObj !== null ) { + return $this->mTargetObj->userCan( $permission, $user ); + } else { + return $user->isAllowed( $permission ); + } + } + + function userCanExecute( User $user ) { + return $this->isAllowed( $this->mRestriction, $user ); + } + + function execute( $par ) { + $this->useTransactionalTimeLimit(); + + $user = $this->getUser(); + + $this->setHeaders(); + $this->outputHeader(); + + $this->loadRequest( $par ); + $this->checkPermissions(); // Needs to be after mTargetObj is set + + $out = $this->getOutput(); + + if ( is_null( $this->mTargetObj ) ) { + $out->addWikiMsg( 'undelete-header' ); + + # Not all users can just browse every deleted page from the list + if ( $user->isAllowed( 'browsearchive' ) ) { + $this->showSearchForm(); + } + + return; + } + + $this->addHelpLink( 'Help:Undelete' ); + if ( $this->mAllowed ) { + $out->setPageTitle( $this->msg( 'undeletepage' ) ); + } else { + $out->setPageTitle( $this->msg( 'viewdeletedpage' ) ); + } + + $this->getSkin()->setRelevantTitle( $this->mTargetObj ); + + if ( $this->mTimestamp !== '' ) { + $this->showRevision( $this->mTimestamp ); + } elseif ( $this->mFilename !== null && $this->mTargetObj->inNamespace( NS_FILE ) ) { + $file = new ArchivedFile( $this->mTargetObj, '', $this->mFilename ); + // Check if user is allowed to see this file + if ( !$file->exists() ) { + $out->addWikiMsg( 'filedelete-nofile', $this->mFilename ); + } elseif ( !$file->userCan( File::DELETED_FILE, $user ) ) { + if ( $file->isDeleted( File::DELETED_RESTRICTED ) ) { + throw new PermissionsError( 'suppressrevision' ); + } else { + throw new PermissionsError( 'deletedtext' ); + } + } elseif ( !$user->matchEditToken( $this->mToken, $this->mFilename ) ) { + $this->showFileConfirmationForm( $this->mFilename ); + } else { + $this->showFile( $this->mFilename ); + } + } elseif ( $this->mAction === "submit" ) { + if ( $this->mRestore ) { + $this->undelete(); + } elseif ( $this->mRevdel ) { + $this->redirectToRevDel(); + } + + } else { + $this->showHistory(); + } + } + + /** + * Convert submitted form data to format expected by RevisionDelete and + * redirect the request + */ + private function redirectToRevDel() { + $archive = new PageArchive( $this->mTargetObj ); + + $revisions = []; + + foreach ( $this->getRequest()->getValues() as $key => $val ) { + $matches = []; + if ( preg_match( "/^ts(\d{14})$/", $key, $matches ) ) { + $revisions[ $archive->getRevision( $matches[1] )->getId() ] = 1; + } + } + $query = [ + "type" => "revision", + "ids" => $revisions, + "target" => $this->mTargetObj->getPrefixedText() + ]; + $url = SpecialPage::getTitleFor( 'Revisiondelete' )->getFullURL( $query ); + $this->getOutput()->redirect( $url ); + } + + function showSearchForm() { + $out = $this->getOutput(); + $out->setPageTitle( $this->msg( 'undelete-search-title' ) ); + $fuzzySearch = $this->getRequest()->getVal( 'fuzzy', true ); + + $out->enableOOUI(); + + $fields[] = new OOUI\ActionFieldLayout( + new OOUI\TextInputWidget( [ + 'name' => 'prefix', + 'inputId' => 'prefix', + 'infusable' => true, + 'value' => $this->mSearchPrefix, + 'autofocus' => true, + ] ), + new OOUI\ButtonInputWidget( [ + 'label' => $this->msg( 'undelete-search-submit' )->text(), + 'flags' => [ 'primary', 'progressive' ], + 'inputId' => 'searchUndelete', + 'type' => 'submit', + ] ), + [ + 'label' => new OOUI\HtmlSnippet( + $this->msg( + $fuzzySearch ? 'undelete-search-full' : 'undelete-search-prefix' + )->parse() + ), + 'align' => 'left', + ] + ); + + $fieldset = new OOUI\FieldsetLayout( [ + 'label' => $this->msg( 'undelete-search-box' )->text(), + 'items' => $fields, + ] ); + + $form = new OOUI\FormLayout( [ + 'method' => 'get', + 'action' => wfScript(), + ] ); + + $form->appendContent( + $fieldset, + new OOUI\HtmlSnippet( + Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) . + Html::hidden( 'fuzzy', $fuzzySearch ) + ) + ); + + $out->addHTML( + new OOUI\PanelLayout( [ + 'expanded' => false, + 'padded' => true, + 'framed' => true, + 'content' => $form, + ] ) + ); + + # List undeletable articles + if ( $this->mSearchPrefix ) { + // For now, we enable search engine match only when specifically asked to + // by using fuzzy=1 parameter. + if ( $fuzzySearch ) { + $result = PageArchive::listPagesBySearch( $this->mSearchPrefix ); + } else { + $result = PageArchive::listPagesByPrefix( $this->mSearchPrefix ); + } + $this->showList( $result ); + } + } + + /** + * Generic list of deleted pages + * + * @param IResultWrapper $result + * @return bool + */ + private function showList( $result ) { + $out = $this->getOutput(); + + if ( $result->numRows() == 0 ) { + $out->addWikiMsg( 'undelete-no-results' ); + + return false; + } + + $out->addWikiMsg( 'undeletepagetext', $this->getLanguage()->formatNum( $result->numRows() ) ); + + $linkRenderer = $this->getLinkRenderer(); + $undelete = $this->getPageTitle(); + $out->addHTML( "<ul id='undeleteResultsList'>\n" ); + foreach ( $result as $row ) { + $title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title ); + if ( $title !== null ) { + $item = $linkRenderer->makeKnownLink( + $undelete, + $title->getPrefixedText(), + [], + [ 'target' => $title->getPrefixedText() ] + ); + } else { + // The title is no longer valid, show as text + $item = Html::element( + 'span', + [ 'class' => 'mw-invalidtitle' ], + Linker::getInvalidTitleDescription( + $this->getContext(), + $row->ar_namespace, + $row->ar_title + ) + ); + } + $revs = $this->msg( 'undeleterevisions' )->numParams( $row->count )->parse(); + $out->addHTML( "<li class='undeleteResult'>{$item} ({$revs})</li>\n" ); + } + $result->free(); + $out->addHTML( "</ul>\n" ); + + return true; + } + + private function showRevision( $timestamp ) { + if ( !preg_match( '/[0-9]{14}/', $timestamp ) ) { + return; + } + + $archive = new PageArchive( $this->mTargetObj, $this->getConfig() ); + if ( !Hooks::run( 'UndeleteForm::showRevision', [ &$archive, $this->mTargetObj ] ) ) { + return; + } + $rev = $archive->getRevision( $timestamp ); + + $out = $this->getOutput(); + $user = $this->getUser(); + + if ( !$rev ) { + $out->addWikiMsg( 'undeleterevision-missing' ); + + return; + } + + if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) { + if ( !$rev->userCan( Revision::DELETED_TEXT, $user ) ) { + $out->wrapWikiMsg( + "<div class='mw-warning plainlinks'>\n$1\n</div>\n", + $rev->isDeleted( Revision::DELETED_RESTRICTED ) ? + 'rev-suppressed-text-permission' : 'rev-deleted-text-permission' + ); + + return; + } + + $out->wrapWikiMsg( + "<div class='mw-warning plainlinks'>\n$1\n</div>\n", + $rev->isDeleted( Revision::DELETED_RESTRICTED ) ? + 'rev-suppressed-text-view' : 'rev-deleted-text-view' + ); + $out->addHTML( '<br />' ); + // and we are allowed to see... + } + + if ( $this->mDiff ) { + $previousRev = $archive->getPreviousRevision( $timestamp ); + if ( $previousRev ) { + $this->showDiff( $previousRev, $rev ); + if ( $this->mDiffOnly ) { + return; + } + + $out->addHTML( '<hr />' ); + } else { + $out->addWikiMsg( 'undelete-nodiff' ); + } + } + + $link = $this->getLinkRenderer()->makeKnownLink( + $this->getPageTitle( $this->mTargetObj->getPrefixedDBkey() ), + $this->mTargetObj->getPrefixedText() + ); + + $lang = $this->getLanguage(); + + // date and time are separate parameters to facilitate localisation. + // $time is kept for backward compat reasons. + $time = $lang->userTimeAndDate( $timestamp, $user ); + $d = $lang->userDate( $timestamp, $user ); + $t = $lang->userTime( $timestamp, $user ); + $userLink = Linker::revUserTools( $rev ); + + $content = $rev->getContent( Revision::FOR_THIS_USER, $user ); + + $isText = ( $content instanceof TextContent ); + + if ( $this->mPreview || $isText ) { + $openDiv = '<div id="mw-undelete-revision" class="mw-warning">'; + } else { + $openDiv = '<div id="mw-undelete-revision">'; + } + $out->addHTML( $openDiv ); + + // Revision delete links + if ( !$this->mDiff ) { + $revdel = Linker::getRevDeleteLink( $user, $rev, $this->mTargetObj ); + if ( $revdel ) { + $out->addHTML( "$revdel " ); + } + } + + $out->addHTML( $this->msg( 'undelete-revision' )->rawParams( $link )->params( + $time )->rawParams( $userLink )->params( $d, $t )->parse() . '</div>' ); + + if ( !Hooks::run( 'UndeleteShowRevision', [ $this->mTargetObj, $rev ] ) ) { + return; + } + + if ( ( $this->mPreview || !$isText ) && $content ) { + // NOTE: non-text content has no source view, so always use rendered preview + + $popts = $out->parserOptions(); + + $pout = $content->getParserOutput( $this->mTargetObj, $rev->getId(), $popts, true ); + $out->addParserOutput( $pout, [ + 'enableSectionEditLinks' => false, + ] ); + } + + $out->enableOOUI(); + $buttonFields = []; + + if ( $isText ) { + // source view for textual content + $sourceView = Xml::element( 'textarea', [ + 'readonly' => 'readonly', + 'cols' => 80, + 'rows' => 25 + ], $content->getNativeData() . "\n" ); + + $buttonFields[] = new OOUI\ButtonInputWidget( [ + 'type' => 'submit', + 'name' => 'preview', + 'label' => $this->msg( 'showpreview' )->text() + ] ); + } else { + $sourceView = ''; + $previewButton = ''; + } + + $buttonFields[] = new OOUI\ButtonInputWidget( [ + 'name' => 'diff', + 'type' => 'submit', + 'label' => $this->msg( 'showdiff' )->text() + ] ); + + $out->addHTML( + $sourceView . + Xml::openElement( 'div', [ + 'style' => 'clear: both' ] ) . + Xml::openElement( 'form', [ + 'method' => 'post', + 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ) ] ) . + Xml::element( 'input', [ + 'type' => 'hidden', + 'name' => 'target', + 'value' => $this->mTargetObj->getPrefixedDBkey() ] ) . + Xml::element( 'input', [ + 'type' => 'hidden', + 'name' => 'timestamp', + 'value' => $timestamp ] ) . + Xml::element( 'input', [ + 'type' => 'hidden', + 'name' => 'wpEditToken', + 'value' => $user->getEditToken() ] ) . + new OOUI\FieldLayout( + new OOUI\Widget( [ + 'content' => new OOUI\HorizontalLayout( [ + 'items' => $buttonFields + ] ) + ] ) + ) . + Xml::closeElement( 'form' ) . + Xml::closeElement( 'div' ) + ); + } + + /** + * Build a diff display between this and the previous either deleted + * or non-deleted edit. + * + * @param Revision $previousRev + * @param Revision $currentRev + * @return string HTML + */ + function showDiff( $previousRev, $currentRev ) { + $diffContext = clone $this->getContext(); + $diffContext->setTitle( $currentRev->getTitle() ); + $diffContext->setWikiPage( WikiPage::factory( $currentRev->getTitle() ) ); + + $diffEngine = $currentRev->getContentHandler()->createDifferenceEngine( $diffContext ); + $diffEngine->showDiffStyle(); + + $formattedDiff = $diffEngine->generateContentDiffBody( + $previousRev->getContent( Revision::FOR_THIS_USER, $this->getUser() ), + $currentRev->getContent( Revision::FOR_THIS_USER, $this->getUser() ) + ); + + $formattedDiff = $diffEngine->addHeader( + $formattedDiff, + $this->diffHeader( $previousRev, 'o' ), + $this->diffHeader( $currentRev, 'n' ) + ); + + $this->getOutput()->addHTML( "<div>$formattedDiff</div>\n" ); + } + + /** + * @param Revision $rev + * @param string $prefix + * @return string + */ + private function diffHeader( $rev, $prefix ) { + $isDeleted = !( $rev->getId() && $rev->getTitle() ); + if ( $isDeleted ) { + /// @todo FIXME: $rev->getTitle() is null for deleted revs...? + $targetPage = $this->getPageTitle(); + $targetQuery = [ + 'target' => $this->mTargetObj->getPrefixedText(), + 'timestamp' => wfTimestamp( TS_MW, $rev->getTimestamp() ) + ]; + } else { + /// @todo FIXME: getId() may return non-zero for deleted revs... + $targetPage = $rev->getTitle(); + $targetQuery = [ 'oldid' => $rev->getId() ]; + } + + // Add show/hide deletion links if available + $user = $this->getUser(); + $lang = $this->getLanguage(); + $rdel = Linker::getRevDeleteLink( $user, $rev, $this->mTargetObj ); + + if ( $rdel ) { + $rdel = " $rdel"; + } + + $minor = $rev->isMinor() ? ChangesList::flag( 'minor' ) : ''; + + $tags = wfGetDB( DB_REPLICA )->selectField( + 'tag_summary', + 'ts_tags', + [ 'ts_rev_id' => $rev->getId() ], + __METHOD__ + ); + $tagSummary = ChangeTags::formatSummaryRow( $tags, 'deleteddiff', $this->getContext() ); + + // FIXME This is reimplementing DifferenceEngine#getRevisionHeader + // and partially #showDiffPage, but worse + return '<div id="mw-diff-' . $prefix . 'title1"><strong>' . + $this->getLinkRenderer()->makeLink( + $targetPage, + $this->msg( + 'revisionasof', + $lang->userTimeAndDate( $rev->getTimestamp(), $user ), + $lang->userDate( $rev->getTimestamp(), $user ), + $lang->userTime( $rev->getTimestamp(), $user ) + )->text(), + [], + $targetQuery + ) . + '</strong></div>' . + '<div id="mw-diff-' . $prefix . 'title2">' . + Linker::revUserTools( $rev ) . '<br />' . + '</div>' . + '<div id="mw-diff-' . $prefix . 'title3">' . + $minor . Linker::revComment( $rev ) . $rdel . '<br />' . + '</div>' . + '<div id="mw-diff-' . $prefix . 'title5">' . + $tagSummary[0] . '<br />' . + '</div>'; + } + + /** + * Show a form confirming whether a tokenless user really wants to see a file + * @param string $key + */ + private function showFileConfirmationForm( $key ) { + $out = $this->getOutput(); + $lang = $this->getLanguage(); + $user = $this->getUser(); + $file = new ArchivedFile( $this->mTargetObj, '', $this->mFilename ); + $out->addWikiMsg( 'undelete-show-file-confirm', + $this->mTargetObj->getText(), + $lang->userDate( $file->getTimestamp(), $user ), + $lang->userTime( $file->getTimestamp(), $user ) ); + $out->addHTML( + Xml::openElement( 'form', [ + 'method' => 'POST', + 'action' => $this->getPageTitle()->getLocalURL( [ + 'target' => $this->mTarget, + 'file' => $key, + 'token' => $user->getEditToken( $key ), + ] ), + ] + ) . + Xml::submitButton( $this->msg( 'undelete-show-file-submit' )->text() ) . + '</form>' + ); + } + + /** + * Show a deleted file version requested by the visitor. + * @param string $key + */ + private function showFile( $key ) { + $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 + $response = $this->getRequest()->response(); + $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' ); + $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' ); + $response->header( 'Pragma: no-cache' ); + + $repo = RepoGroup::singleton()->getLocalRepo(); + $path = $repo->getZonePath( 'deleted' ) . '/' . $repo->getDeletedHashPath( $key ) . $key; + $repo->streamFile( $path ); + } + + protected function showHistory() { + $this->checkReadOnly(); + + $out = $this->getOutput(); + if ( $this->mAllowed ) { + $out->addModules( 'mediawiki.special.undelete' ); + } + $out->wrapWikiMsg( + "<div class='mw-undelete-pagetitle'>\n$1\n</div>\n", + [ 'undeletepagetitle', wfEscapeWikiText( $this->mTargetObj->getPrefixedText() ) ] + ); + + $archive = new PageArchive( $this->mTargetObj, $this->getConfig() ); + Hooks::run( 'UndeleteForm::showHistory', [ &$archive, $this->mTargetObj ] ); + + $out->addHTML( '<div class="mw-undelete-history">' ); + if ( $this->mAllowed ) { + $out->addWikiMsg( 'undeletehistory' ); + $out->addWikiMsg( 'undeleterevdel' ); + } else { + $out->addWikiMsg( 'undeletehistorynoadmin' ); + } + $out->addHTML( '</div>' ); + + # List all stored revisions + $revisions = $archive->listRevisions(); + $files = $archive->listFiles(); + + $haveRevisions = $revisions && $revisions->numRows() > 0; + $haveFiles = $files && $files->numRows() > 0; + + # Batch existence check on user and talk pages + if ( $haveRevisions ) { + $batch = new LinkBatch(); + foreach ( $revisions as $row ) { + $batch->addObj( Title::makeTitleSafe( NS_USER, $row->ar_user_text ) ); + $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->ar_user_text ) ); + } + $batch->execute(); + $revisions->seek( 0 ); + } + if ( $haveFiles ) { + $batch = new LinkBatch(); + foreach ( $files as $row ) { + $batch->addObj( Title::makeTitleSafe( NS_USER, $row->fa_user_text ) ); + $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->fa_user_text ) ); + } + $batch->execute(); + $files->seek( 0 ); + } + + if ( $this->mAllowed ) { + $out->enableOOUI(); + + $action = $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ); + # Start the form here + $form = new OOUI\FormLayout( [ + 'method' => 'post', + 'action' => $action, + 'id' => 'undelete', + ] ); + } + + # Show relevant lines from the deletion log: + $deleteLogPage = new LogPage( 'delete' ); + $out->addHTML( Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) . "\n" ); + LogEventsList::showLogExtract( $out, 'delete', $this->mTargetObj ); + # Show relevant lines from the suppression log: + $suppressLogPage = new LogPage( 'suppress' ); + if ( $this->getUser()->isAllowed( 'suppressionlog' ) ) { + $out->addHTML( Xml::element( 'h2', null, $suppressLogPage->getName()->text() ) . "\n" ); + LogEventsList::showLogExtract( $out, 'suppress', $this->mTargetObj ); + } + + if ( $this->mAllowed && ( $haveRevisions || $haveFiles ) ) { + $fields[] = new OOUI\Layout( [ + 'content' => new OOUI\HtmlSnippet( $this->msg( 'undeleteextrahelp' )->parseAsBlock() ) + ] ); + + $conf = $this->getConfig(); + $oldCommentSchema = $conf->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD; + + $fields[] = new OOUI\FieldLayout( + new OOUI\TextInputWidget( [ + 'name' => 'wpComment', + 'inputId' => 'wpComment', + 'infusable' => true, + 'value' => $this->mComment, + 'autofocus' => true, + // 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 (or 255 UTF-8 bytes for old schema). + 'maxLength' => $oldCommentSchema ? 255 : CommentStore::COMMENT_CHARACTER_LIMIT, + ] ), + [ + 'label' => $this->msg( 'undeletecomment' )->text(), + 'align' => 'top', + ] + ); + + $fields[] = new OOUI\FieldLayout( + new OOUI\Widget( [ + 'content' => new OOUI\HorizontalLayout( [ + 'items' => [ + new OOUI\ButtonInputWidget( [ + 'name' => 'restore', + 'inputId' => 'mw-undelete-submit', + 'value' => '1', + 'label' => $this->msg( 'undeletebtn' )->text(), + 'flags' => [ 'primary', 'progressive' ], + 'type' => 'submit', + ] ), + new OOUI\ButtonInputWidget( [ + 'name' => 'invert', + 'inputId' => 'mw-undelete-invert', + 'value' => '1', + 'label' => $this->msg( 'undeleteinvert' )->text() + ] ), + ] + ] ) + ] ) + ); + + if ( $this->getUser()->isAllowed( 'suppressrevision' ) ) { + $fields[] = new OOUI\FieldLayout( + new OOUI\CheckboxInputWidget( [ + 'name' => 'wpUnsuppress', + 'inputId' => 'mw-undelete-unsuppress', + 'value' => '1', + ] ), + [ + 'label' => $this->msg( 'revdelete-unsuppress' )->text(), + 'align' => 'inline', + ] + ); + } + + $fieldset = new OOUI\FieldsetLayout( [ + 'label' => $this->msg( 'undelete-fieldset-title' )->text(), + 'id' => 'mw-undelete-table', + 'items' => $fields, + ] ); + + $form->appendContent( + new OOUI\PanelLayout( [ + 'expanded' => false, + 'padded' => true, + 'framed' => true, + 'content' => $fieldset, + ] ), + new OOUI\HtmlSnippet( + Html::hidden( 'target', $this->mTarget ) . + Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) + ) + ); + } + + $history = ''; + $history .= Xml::element( 'h2', null, $this->msg( 'history' )->text() ) . "\n"; + + if ( $haveRevisions ) { + # Show the page's stored (deleted) history + + if ( $this->getUser()->isAllowed( 'deleterevision' ) ) { + $history .= Html::element( + 'button', + [ + 'name' => 'revdel', + 'type' => 'submit', + 'class' => 'deleterevision-log-submit mw-log-deleterevision-button' + ], + $this->msg( 'showhideselectedversions' )->text() + ) . "\n"; + } + + $history .= '<ul class="mw-undelete-revlist">'; + $remaining = $revisions->numRows(); + $earliestLiveTime = $this->mTargetObj->getEarliestRevTime(); + + foreach ( $revisions as $row ) { + $remaining--; + $history .= $this->formatRevisionRow( $row, $earliestLiveTime, $remaining ); + } + $revisions->free(); + $history .= '</ul>'; + } else { + $out->addWikiMsg( 'nohistory' ); + } + + if ( $haveFiles ) { + $history .= Xml::element( 'h2', null, $this->msg( 'filehist' )->text() ) . "\n"; + $history .= '<ul class="mw-undelete-revlist">'; + foreach ( $files as $row ) { + $history .= $this->formatFileRow( $row ); + } + $files->free(); + $history .= '</ul>'; + } + + if ( $this->mAllowed ) { + # Slip in the hidden controls here + $misc = Html::hidden( 'target', $this->mTarget ); + $misc .= Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ); + $history .= $misc; + + $form->appendContent( new OOUI\HtmlSnippet( $history ) ); + $out->addHTML( $form ); + } else { + $out->addHTML( $history ); + } + + return true; + } + + protected function formatRevisionRow( $row, $earliestLiveTime, $remaining ) { + $rev = Revision::newFromArchiveRow( $row, + [ + 'title' => $this->mTargetObj + ] ); + + $revTextSize = ''; + $ts = wfTimestamp( TS_MW, $row->ar_timestamp ); + // Build checkboxen... + if ( $this->mAllowed ) { + if ( $this->mInvert ) { + if ( in_array( $ts, $this->mTargetTimestamp ) ) { + $checkBox = Xml::check( "ts$ts" ); + } else { + $checkBox = Xml::check( "ts$ts", true ); + } + } else { + $checkBox = Xml::check( "ts$ts" ); + } + } else { + $checkBox = ''; + } + + // Build page & diff links... + $user = $this->getUser(); + if ( $this->mCanView ) { + $titleObj = $this->getPageTitle(); + # Last link + if ( !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) { + $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) ); + $last = $this->msg( 'diff' )->escaped(); + } elseif ( $remaining > 0 || ( $earliestLiveTime && $ts > $earliestLiveTime ) ) { + $pageLink = $this->getPageLink( $rev, $titleObj, $ts ); + $last = $this->getLinkRenderer()->makeKnownLink( + $titleObj, + $this->msg( 'diff' )->text(), + [], + [ + 'target' => $this->mTargetObj->getPrefixedText(), + 'timestamp' => $ts, + 'diff' => 'prev' + ] + ); + } else { + $pageLink = $this->getPageLink( $rev, $titleObj, $ts ); + $last = $this->msg( 'diff' )->escaped(); + } + } else { + $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) ); + $last = $this->msg( 'diff' )->escaped(); + } + + // User links + $userLink = Linker::revUserTools( $rev ); + + // Minor edit + $minor = $rev->isMinor() ? ChangesList::flag( 'minor' ) : ''; + + // Revision text size + $size = $row->ar_len; + if ( !is_null( $size ) ) { + $revTextSize = Linker::formatRevisionSize( $size ); + } + + // Edit summary + $comment = Linker::revComment( $rev ); + + // Tags + $attribs = []; + list( $tagSummary, $classes ) = ChangeTags::formatSummaryRow( + $row->ts_tags, + 'deletedhistory', + $this->getContext() + ); + if ( $classes ) { + $attribs['class'] = implode( ' ', $classes ); + } + + $revisionRow = $this->msg( 'undelete-revision-row2' ) + ->rawParams( + $checkBox, + $last, + $pageLink, + $userLink, + $minor, + $revTextSize, + $comment, + $tagSummary + ) + ->escaped(); + + return Xml::tags( 'li', $attribs, $revisionRow ) . "\n"; + } + + private function formatFileRow( $row ) { + $file = ArchivedFile::newFromRow( $row ); + $ts = wfTimestamp( TS_MW, $row->fa_timestamp ); + $user = $this->getUser(); + + $checkBox = ''; + if ( $this->mCanView && $row->fa_storage_key ) { + if ( $this->mAllowed ) { + $checkBox = Xml::check( 'fileid' . $row->fa_id ); + } + $key = urlencode( $row->fa_storage_key ); + $pageLink = $this->getFileLink( $file, $this->getPageTitle(), $ts, $key ); + } else { + $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) ); + } + $userLink = $this->getFileUser( $file ); + $data = $this->msg( 'widthheight' )->numParams( $row->fa_width, $row->fa_height )->text(); + $bytes = $this->msg( 'parentheses' ) + ->plaintextParams( $this->msg( 'nbytes' )->numParams( $row->fa_size )->text() ) + ->plain(); + $data = htmlspecialchars( $data . ' ' . $bytes ); + $comment = $this->getFileComment( $file ); + + // Add show/hide deletion links if available + $canHide = $this->isAllowed( 'deleterevision' ); + if ( $canHide || ( $file->getVisibility() && $this->isAllowed( 'deletedhistory' ) ) ) { + if ( !$file->userCan( File::DELETED_RESTRICTED, $user ) ) { + // Revision was hidden from sysops + $revdlink = Linker::revDeleteLinkDisabled( $canHide ); + } else { + $query = [ + 'type' => 'filearchive', + 'target' => $this->mTargetObj->getPrefixedDBkey(), + 'ids' => $row->fa_id + ]; + $revdlink = Linker::revDeleteLink( $query, + $file->isDeleted( File::DELETED_RESTRICTED ), $canHide ); + } + } else { + $revdlink = ''; + } + + return "<li>$checkBox $revdlink $pageLink . . $userLink $data $comment</li>\n"; + } + + /** + * Fetch revision text link if it's available to all users + * + * @param Revision $rev + * @param Title $titleObj + * @param string $ts Timestamp + * @return string + */ + function getPageLink( $rev, $titleObj, $ts ) { + $user = $this->getUser(); + $time = $this->getLanguage()->userTimeAndDate( $ts, $user ); + + if ( !$rev->userCan( Revision::DELETED_TEXT, $user ) ) { + return '<span class="history-deleted">' . $time . '</span>'; + } + + $link = $this->getLinkRenderer()->makeKnownLink( + $titleObj, + $time, + [], + [ + 'target' => $this->mTargetObj->getPrefixedText(), + 'timestamp' => $ts + ] + ); + + if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) { + $link = '<span class="history-deleted">' . $link . '</span>'; + } + + return $link; + } + + /** + * Fetch image view link if it's available to all users + * + * @param File|ArchivedFile $file + * @param Title $titleObj + * @param string $ts A timestamp + * @param string $key A storage key + * + * @return string HTML fragment + */ + function getFileLink( $file, $titleObj, $ts, $key ) { + $user = $this->getUser(); + $time = $this->getLanguage()->userTimeAndDate( $ts, $user ); + + if ( !$file->userCan( File::DELETED_FILE, $user ) ) { + return '<span class="history-deleted">' . htmlspecialchars( $time ) . '</span>'; + } + + $link = $this->getLinkRenderer()->makeKnownLink( + $titleObj, + $time, + [], + [ + 'target' => $this->mTargetObj->getPrefixedText(), + 'file' => $key, + 'token' => $user->getEditToken( $key ) + ] + ); + + if ( $file->isDeleted( File::DELETED_FILE ) ) { + $link = '<span class="history-deleted">' . $link . '</span>'; + } + + return $link; + } + + /** + * Fetch file's user id if it's available to this user + * + * @param File|ArchivedFile $file + * @return string HTML fragment + */ + function getFileUser( $file ) { + if ( !$file->userCan( File::DELETED_USER, $this->getUser() ) ) { + return '<span class="history-deleted">' . + $this->msg( 'rev-deleted-user' )->escaped() . + '</span>'; + } + + $link = Linker::userLink( $file->getRawUser(), $file->getRawUserText() ) . + Linker::userToolLinks( $file->getRawUser(), $file->getRawUserText() ); + + if ( $file->isDeleted( File::DELETED_USER ) ) { + $link = '<span class="history-deleted">' . $link . '</span>'; + } + + return $link; + } + + /** + * Fetch file upload comment if it's available to this user + * + * @param File|ArchivedFile $file + * @return string HTML fragment + */ + function getFileComment( $file ) { + if ( !$file->userCan( File::DELETED_COMMENT, $this->getUser() ) ) { + return '<span class="history-deleted"><span class="comment">' . + $this->msg( 'rev-deleted-comment' )->escaped() . '</span></span>'; + } + + $link = Linker::commentBlock( $file->getRawDescription() ); + + if ( $file->isDeleted( File::DELETED_COMMENT ) ) { + $link = '<span class="history-deleted">' . $link . '</span>'; + } + + return $link; + } + + function undelete() { + if ( $this->getConfig()->get( 'UploadMaintenance' ) + && $this->mTargetObj->getNamespace() == NS_FILE + ) { + throw new ErrorPageError( 'undelete-error', 'filedelete-maintenance' ); + } + + $this->checkReadOnly(); + + $out = $this->getOutput(); + $archive = new PageArchive( $this->mTargetObj, $this->getConfig() ); + Hooks::run( 'UndeleteForm::undelete', [ &$archive, $this->mTargetObj ] ); + $ok = $archive->undelete( + $this->mTargetTimestamp, + $this->mComment, + $this->mFileVersions, + $this->mUnsuppress, + $this->getUser() + ); + + if ( is_array( $ok ) ) { + if ( $ok[1] ) { // Undeleted file count + Hooks::run( 'FileUndeleteComplete', [ + $this->mTargetObj, $this->mFileVersions, + $this->getUser(), $this->mComment ] ); + } + + $link = $this->getLinkRenderer()->makeKnownLink( $this->mTargetObj ); + $out->addHTML( $this->msg( 'undeletedpage' )->rawParams( $link )->parse() ); + } else { + $out->setPageTitle( $this->msg( 'undelete-error' ) ); + } + + // Show revision undeletion warnings and errors + $status = $archive->getRevisionStatus(); + if ( $status && !$status->isGood() ) { + $out->addWikiText( '<div class="error" id="mw-error-cannotundelete">' . + $status->getWikiText( + 'cannotundelete', + 'cannotundelete' + ) . '</div>' + ); + } + + // Show file undeletion warnings and errors + $status = $archive->getFileStatus(); + if ( $status && !$status->isGood() ) { + $out->addWikiText( '<div class="error">' . + $status->getWikiText( + 'undelete-error-short', + 'undelete-error-long' + ) . '</div>' + ); + } + } + + /** + * 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/www/wiki/includes/specials/SpecialUnlinkAccounts.php b/www/wiki/includes/specials/SpecialUnlinkAccounts.php new file mode 100644 index 00000000..b159fff1 --- /dev/null +++ b/www/wiki/includes/specials/SpecialUnlinkAccounts.php @@ -0,0 +1,79 @@ +<?php + +use MediaWiki\Auth\AuthenticationResponse; +use MediaWiki\Auth\AuthManager; +use MediaWiki\Session\SessionManager; + +class SpecialUnlinkAccounts extends AuthManagerSpecialPage { + protected static $allowedActions = [ AuthManager::ACTION_UNLINK ]; + + public function __construct() { + parent::__construct( 'UnlinkAccounts' ); + } + + protected function getLoginSecurityLevel() { + return 'UnlinkAccount'; + } + + protected function getDefaultAction( $subPage ) { + return AuthManager::ACTION_UNLINK; + } + + /** + * Under which header this special page is listed in Special:SpecialPages. + * @return string + */ + protected function getGroupName() { + return 'users'; + } + + public function isListed() { + return AuthManager::singleton()->canLinkAccounts(); + } + + protected function getRequestBlacklist() { + return $this->getConfig()->get( 'RemoveCredentialsBlacklist' ); + } + + public function execute( $subPage ) { + $this->setHeaders(); + $this->loadAuth( $subPage ); + $this->outputHeader(); + + $status = $this->trySubmit(); + + if ( $status === false || !$status->isOK() ) { + $this->displayForm( $status ); + return; + } + + /** @var AuthenticationResponse $response */ + $response = $status->getValue(); + + if ( $response->status === AuthenticationResponse::FAIL ) { + $this->displayForm( StatusValue::newFatal( $response->message ) ); + return; + } + + $status = StatusValue::newGood(); + $status->warning( wfMessage( 'unlinkaccounts-success' ) ); + $this->loadAuth( $subPage, null, true ); // update requests so the unlinked one doesn't show up + + // Reset sessions - if the user unlinked an account because it was compromised, + // log attackers out from sessions obtained via that account. + $session = $this->getRequest()->getSession(); + $user = $this->getUser(); + SessionManager::singleton()->invalidateSessionsForUser( $user ); + $session->setUser( $user ); + $session->resetId(); + + $this->displayForm( $status ); + } + + public function handleFormSubmit( $data ) { + // unlink requests do not accept user input so repeat parent code but skip call to + // AuthenticationRequest::loadRequestsFromSubmission + $response = $this->performAuthenticationStep( $this->authAction, $this->authRequests ); + return Status::newGood( $response ); + } +} diff --git a/www/wiki/includes/specials/SpecialUnlockdb.php b/www/wiki/includes/specials/SpecialUnlockdb.php new file mode 100644 index 00000000..3135653c --- /dev/null +++ b/www/wiki/includes/specials/SpecialUnlockdb.php @@ -0,0 +1,96 @@ +<?php +/** + * Implements Special:Unlockdb + * + * 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 + */ + +/** + * Implements Special:Unlockdb + * + * @ingroup SpecialPage + */ +class SpecialUnlockdb extends FormSpecialPage { + + public function __construct() { + parent::__construct( 'Unlockdb', 'siteadmin' ); + } + + public function doesWrites() { + return false; + } + + public function requiresWrite() { + return false; + } + + public function checkExecutePermissions( User $user ) { + parent::checkExecutePermissions( $user ); + # If the lock file isn't writable, we can do sweet bugger all + if ( !file_exists( $this->getConfig()->get( 'ReadOnlyFile' ) ) ) { + throw new ErrorPageError( 'lockdb', 'databasenotlocked' ); + } + } + + protected function getFormFields() { + return [ + 'Confirm' => [ + 'type' => 'toggle', + 'label-message' => 'unlockconfirm', + ], + ]; + } + + protected function alterForm( HTMLForm $form ) { + $form->setWrapperLegend( false ) + ->setHeaderText( $this->msg( 'unlockdbtext' )->parseAsBlock() ) + ->setSubmitTextMsg( 'unlockbtn' ); + } + + public function onSubmit( array $data ) { + if ( !$data['Confirm'] ) { + return Status::newFatal( 'locknoconfirm' ); + } + + $readOnlyFile = $this->getConfig()->get( 'ReadOnlyFile' ); + Wikimedia\suppressWarnings(); + $res = unlink( $readOnlyFile ); + Wikimedia\restoreWarnings(); + + if ( $res ) { + return Status::newGood(); + } else { + return Status::newFatal( 'filedeleteerror', $readOnlyFile ); + } + } + + public function onSuccess() { + $out = $this->getOutput(); + $out->addSubtitle( $this->msg( 'unlockdbsuccesssub' ) ); + $out->addWikiMsg( 'unlockdbsuccesstext' ); + } + + protected function getDisplayFormat() { + return 'ooui'; + } + + protected function getGroupName() { + return 'wiki'; + } +} diff --git a/www/wiki/includes/specials/SpecialUnusedcategories.php b/www/wiki/includes/specials/SpecialUnusedcategories.php new file mode 100644 index 00000000..1469742a --- /dev/null +++ b/www/wiki/includes/specials/SpecialUnusedcategories.php @@ -0,0 +1,83 @@ +<?php +/** + * Implements Special:Unusedcategories + * + * 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 UnusedCategoriesPage extends QueryPage { + function __construct( $name = 'Unusedcategories' ) { + parent::__construct( $name ); + } + + public function isExpensive() { + return true; + } + + function getPageHeader() { + return $this->msg( 'unusedcategoriestext' )->parseAsBlock(); + } + + public function getQueryInfo() { + return [ + 'tables' => [ 'page', 'categorylinks' ], + 'fields' => [ + 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'page_title' + ], + 'conds' => [ + 'cl_from IS NULL', + 'page_namespace' => NS_CATEGORY, + 'page_is_redirect' => 0 + ], + 'join_conds' => [ 'categorylinks' => [ 'LEFT JOIN', 'cl_to = page_title' ] ] + ]; + } + + /** + * A should come before Z (T32907) + * @return bool + */ + function sortDescending() { + return false; + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + $title = Title::makeTitle( NS_CATEGORY, $result->title ); + + return $this->getLinkRenderer()->makeLink( $title, $title->getText() ); + } + + protected function getGroupName() { + return 'maintenance'; + } + + public function preprocessResults( $db, $res ) { + $this->executeLBFromResultWrapper( $res ); + } +} diff --git a/www/wiki/includes/specials/SpecialUnusedimages.php b/www/wiki/includes/specials/SpecialUnusedimages.php new file mode 100644 index 00000000..9fcbf15f --- /dev/null +++ b/www/wiki/includes/specials/SpecialUnusedimages.php @@ -0,0 +1,85 @@ +<?php +/** + * Implements Special:Unusedimages + * + * 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 + */ + +/** + * A special page that lists unused images + * + * @ingroup SpecialPage + */ +class UnusedimagesPage extends ImageQueryPage { + function __construct( $name = 'Unusedimages' ) { + parent::__construct( $name ); + } + + function isExpensive() { + return true; + } + + function sortDescending() { + return false; + } + + function isSyndicated() { + return false; + } + + function getQueryInfo() { + $retval = [ + 'tables' => [ 'image', 'imagelinks' ], + 'fields' => [ + 'namespace' => NS_FILE, + 'title' => 'img_name', + 'value' => 'img_timestamp', + ], + 'conds' => [ 'il_to IS NULL' ], + 'join_conds' => [ 'imagelinks' => [ 'LEFT JOIN', 'il_to = img_name' ] ] + ]; + + if ( $this->getConfig()->get( 'CountCategorizedImagesAsUsed' ) ) { + // Order is significant + $retval['tables'] = [ 'image', 'page', 'categorylinks', + 'imagelinks' ]; + $retval['conds']['page_namespace'] = NS_FILE; + $retval['conds'][] = 'cl_from IS NULL'; + $retval['conds'][] = 'img_name = page_title'; + $retval['join_conds']['categorylinks'] = [ + 'LEFT JOIN', 'cl_from = page_id' ]; + $retval['join_conds']['imagelinks'] = [ + 'LEFT JOIN', 'il_to = page_title' ]; + } + + return $retval; + } + + function usesTimestamps() { + return true; + } + + function getPageHeader() { + return $this->msg( 'unusedimagestext' )->parseAsBlock(); + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialUnusedtemplates.php b/www/wiki/includes/specials/SpecialUnusedtemplates.php new file mode 100644 index 00000000..f73be438 --- /dev/null +++ b/www/wiki/includes/specials/SpecialUnusedtemplates.php @@ -0,0 +1,97 @@ +<?php +/** + * Implements Special:Unusedtemplates + * + * Copyright © 2006 Rob Church + * + * 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 + * @author Rob Church <robchur@gmail.com> + */ + +/** + * A special page that lists unused templates + * + * @ingroup SpecialPage + */ +class UnusedtemplatesPage extends QueryPage { + function __construct( $name = 'Unusedtemplates' ) { + parent::__construct( $name ); + } + + public function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + function sortDescending() { + return false; + } + + public function getQueryInfo() { + return [ + 'tables' => [ 'page', 'templatelinks' ], + 'fields' => [ + 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'page_title' + ], + 'conds' => [ + 'page_namespace' => NS_TEMPLATE, + 'tl_from IS NULL', + 'page_is_redirect' => 0 + ], + 'join_conds' => [ 'templatelinks' => [ + 'LEFT JOIN', [ 'tl_title = page_title', + 'tl_namespace = page_namespace' ] ] ] + ]; + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + $linkRenderer = $this->getLinkRenderer(); + $title = Title::makeTitle( NS_TEMPLATE, $result->title ); + $pageLink = $linkRenderer->makeKnownLink( + $title, + null, + [], + [ 'redirect' => 'no' ] + ); + $wlhLink = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Whatlinkshere', $title->getPrefixedText() ), + $this->msg( 'unusedtemplateswlh' )->text() + ); + + return $this->getLanguage()->specialList( $pageLink, $wlhLink ); + } + + function getPageHeader() { + return $this->msg( 'unusedtemplatestext' )->parseAsBlock(); + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialUnwatchedpages.php b/www/wiki/includes/specials/SpecialUnwatchedpages.php new file mode 100644 index 00000000..0ea7dfae --- /dev/null +++ b/www/wiki/includes/specials/SpecialUnwatchedpages.php @@ -0,0 +1,138 @@ +<?php +/** + * Implements Special:Unwatchedpages + * + * Copyright © 2005 Ævar Arnfjörð Bjarmason + * + * 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 + * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com> + */ + +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * A special page that displays a list of pages that are not on anyones watchlist. + * + * @ingroup SpecialPage + */ +class UnwatchedpagesPage extends QueryPage { + + function __construct( $name = 'Unwatchedpages' ) { + parent::__construct( $name, 'unwatchedpages' ); + } + + public function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + /** + * Pre-cache page existence to speed up link generation + * + * @param IDatabase $db + * @param IResultWrapper $res + */ + public function preprocessResults( $db, $res ) { + if ( !$res->numRows() ) { + return; + } + + $batch = new LinkBatch(); + foreach ( $res as $row ) { + $batch->add( $row->namespace, $row->title ); + } + $batch->execute(); + + $res->seek( 0 ); + } + + public function getQueryInfo() { + $dbr = wfGetDB( DB_REPLICA ); + return [ + 'tables' => [ 'page', 'watchlist' ], + 'fields' => [ + 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'page_namespace' + ], + 'conds' => [ + 'wl_title IS NULL', + 'page_is_redirect' => 0, + 'page_namespace != ' . $dbr->addQuotes( NS_MEDIAWIKI ), + ], + 'join_conds' => [ 'watchlist' => [ + 'LEFT JOIN', [ 'wl_title = page_title', + 'wl_namespace = page_namespace' ] ] ] + ]; + } + + function sortDescending() { + return false; + } + + function getOrderFields() { + return [ 'page_namespace', 'page_title' ]; + } + + /** + * Add the JS + * @param string|null $par + */ + public function execute( $par ) { + parent::execute( $par ); + $this->getOutput()->addModules( 'mediawiki.special.unwatchedPages' ); + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + global $wgContLang; + + $nt = Title::makeTitleSafe( $result->namespace, $result->title ); + if ( !$nt ) { + return Html::element( 'span', [ 'class' => 'mw-invalidtitle' ], + Linker::getInvalidTitleDescription( $this->getContext(), $result->namespace, $result->title ) ); + } + + $text = $wgContLang->convert( $nt->getPrefixedText() ); + + $linkRenderer = $this->getLinkRenderer(); + + $plink = $linkRenderer->makeKnownLink( $nt, $text ); + $wlink = $linkRenderer->makeKnownLink( + $nt, + $this->msg( 'watch' )->text(), + [ 'class' => 'mw-watch-link' ], + [ 'action' => 'watch' ] + ); + + return $this->getLanguage()->specialList( $plink, $wlink ); + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialUpload.php b/www/wiki/includes/specials/SpecialUpload.php new file mode 100644 index 00000000..f7cb6545 --- /dev/null +++ b/www/wiki/includes/specials/SpecialUpload.php @@ -0,0 +1,853 @@ +<?php +/** + * Implements Special:Upload + * + * 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 Upload + */ + +use MediaWiki\MediaWikiServices; + +/** + * Form for handling uploads and special page. + * + * @ingroup SpecialPage + * @ingroup Upload + */ +class SpecialUpload extends SpecialPage { + /** + * Get data POSTed through the form and assign them to the object + * @param WebRequest $request Data posted. + */ + public function __construct( $request = null ) { + parent::__construct( 'Upload', 'upload' ); + } + + public function doesWrites() { + return true; + } + + /** Misc variables **/ + + /** @var WebRequest|FauxRequest The request this form is supposed to handle */ + public $mRequest; + public $mSourceType; + + /** @var UploadBase */ + public $mUpload; + + /** @var LocalFile */ + public $mLocalFile; + public $mUploadClicked; + + /** User input variables from the "description" section **/ + + /** @var string The requested target file name */ + public $mDesiredDestName; + public $mComment; + public $mLicense; + + /** User input variables from the root section **/ + + public $mIgnoreWarning; + public $mWatchthis; + public $mCopyrightStatus; + public $mCopyrightSource; + + /** Hidden variables **/ + + public $mDestWarningAck; + + /** @var bool The user followed an "overwrite this file" link */ + public $mForReUpload; + + /** @var bool The user clicked "Cancel and return to upload form" button */ + public $mCancelUpload; + public $mTokenOk; + + /** @var bool Subclasses can use this to determine whether a file was uploaded */ + public $mUploadSuccessful = false; + + /** Text injection points for hooks not using HTMLForm **/ + public $uploadFormTextTop; + public $uploadFormTextAfterSummary; + + /** + * Initialize instance variables from request and create an Upload handler + */ + protected function loadRequest() { + $this->mRequest = $request = $this->getRequest(); + $this->mSourceType = $request->getVal( 'wpSourceType', 'file' ); + $this->mUpload = UploadBase::createFromRequest( $request ); + $this->mUploadClicked = $request->wasPosted() + && ( $request->getCheck( 'wpUpload' ) + || $request->getCheck( 'wpUploadIgnoreWarning' ) ); + + // Guess the desired name from the filename if not provided + $this->mDesiredDestName = $request->getText( 'wpDestFile' ); + if ( !$this->mDesiredDestName && $request->getFileName( 'wpUploadFile' ) !== null ) { + $this->mDesiredDestName = $request->getFileName( 'wpUploadFile' ); + } + $this->mLicense = $request->getText( 'wpLicense' ); + + $this->mDestWarningAck = $request->getText( 'wpDestFileWarningAck' ); + $this->mIgnoreWarning = $request->getCheck( 'wpIgnoreWarning' ) + || $request->getCheck( 'wpUploadIgnoreWarning' ); + $this->mWatchthis = $request->getBool( 'wpWatchthis' ) && $this->getUser()->isLoggedIn(); + $this->mCopyrightStatus = $request->getText( 'wpUploadCopyStatus' ); + $this->mCopyrightSource = $request->getText( 'wpUploadSource' ); + + $this->mForReUpload = $request->getBool( 'wpForReUpload' ); // updating a file + + $commentDefault = ''; + $commentMsg = wfMessage( 'upload-default-description' )->inContentLanguage(); + if ( !$this->mForReUpload && !$commentMsg->isDisabled() ) { + $commentDefault = $commentMsg->plain(); + } + $this->mComment = $request->getText( 'wpUploadDescription', $commentDefault ); + + $this->mCancelUpload = $request->getCheck( 'wpCancelUpload' ) + || $request->getCheck( 'wpReUpload' ); // b/w compat + + // If it was posted check for the token (no remote POST'ing with user credentials) + $token = $request->getVal( 'wpEditToken' ); + $this->mTokenOk = $this->getUser()->matchEditToken( $token ); + + $this->uploadFormTextTop = ''; + $this->uploadFormTextAfterSummary = ''; + } + + /** + * This page can be shown if uploading is enabled. + * Handle permission checking elsewhere in order to be able to show + * custom error messages. + * + * @param User $user + * @return bool + */ + public function userCanExecute( User $user ) { + return UploadBase::isEnabled() && parent::userCanExecute( $user ); + } + + /** + * Special page entry point + * @param string $par + * @throws ErrorPageError + * @throws Exception + * @throws FatalError + * @throws MWException + * @throws PermissionsError + * @throws ReadOnlyError + * @throws UserBlockedError + */ + public function execute( $par ) { + $this->useTransactionalTimeLimit(); + + $this->setHeaders(); + $this->outputHeader(); + + # Check uploading enabled + if ( !UploadBase::isEnabled() ) { + throw new ErrorPageError( 'uploaddisabled', 'uploaddisabledtext' ); + } + + $this->addHelpLink( 'Help:Managing files' ); + + # Check permissions + $user = $this->getUser(); + $permissionRequired = UploadBase::isAllowed( $user ); + if ( $permissionRequired !== true ) { + throw new PermissionsError( $permissionRequired ); + } + + # Check blocks + if ( $user->isBlocked() ) { + throw new UserBlockedError( $user->getBlock() ); + } + + // Global blocks + if ( $user->isBlockedGlobally() ) { + throw new UserBlockedError( $user->getGlobalBlock() ); + } + + # Check whether we actually want to allow changing stuff + $this->checkReadOnly(); + + $this->loadRequest(); + + # Unsave the temporary file in case this was a cancelled upload + if ( $this->mCancelUpload ) { + if ( !$this->unsaveUploadedFile() ) { + # Something went wrong, so unsaveUploadedFile showed a warning + return; + } + } + + # Process upload or show a form + if ( + $this->mTokenOk && !$this->mCancelUpload && + ( $this->mUpload && $this->mUploadClicked ) + ) { + $this->processUpload(); + } else { + # Backwards compatibility hook + // Avoid PHP 7.1 warning of passing $this by reference + $upload = $this; + if ( !Hooks::run( 'UploadForm:initial', [ &$upload ] ) ) { + wfDebug( "Hook 'UploadForm:initial' broke output of the upload form\n" ); + + return; + } + $this->showUploadForm( $this->getUploadForm() ); + } + + # Cleanup + if ( $this->mUpload ) { + $this->mUpload->cleanupTempFile(); + } + } + + /** + * Show the main upload form + * + * @param HTMLForm|string $form An HTMLForm instance or HTML string to show + */ + protected function showUploadForm( $form ) { + # Add links if file was previously deleted + if ( $this->mDesiredDestName ) { + $this->showViewDeletedLinks(); + } + + if ( $form instanceof HTMLForm ) { + $form->show(); + } else { + $this->getOutput()->addHTML( $form ); + } + } + + /** + * Get an UploadForm instance with title and text properly set. + * + * @param string $message HTML string to add to the form + * @param string $sessionKey Session key in case this is a stashed upload + * @param bool $hideIgnoreWarning Whether to hide "ignore warning" check box + * @return UploadForm + */ + protected function getUploadForm( $message = '', $sessionKey = '', $hideIgnoreWarning = false ) { + # Initialize form + $context = new DerivativeContext( $this->getContext() ); + $context->setTitle( $this->getPageTitle() ); // Remove subpage + $form = new UploadForm( [ + 'watch' => $this->getWatchCheck(), + 'forreupload' => $this->mForReUpload, + 'sessionkey' => $sessionKey, + 'hideignorewarning' => $hideIgnoreWarning, + 'destwarningack' => (bool)$this->mDestWarningAck, + + 'description' => $this->mComment, + 'texttop' => $this->uploadFormTextTop, + 'textaftersummary' => $this->uploadFormTextAfterSummary, + 'destfile' => $this->mDesiredDestName, + ], $context, $this->getLinkRenderer() ); + + # Check the token, but only if necessary + if ( + !$this->mTokenOk && !$this->mCancelUpload && + ( $this->mUpload && $this->mUploadClicked ) + ) { + $form->addPreText( $this->msg( 'session_fail_preview' )->parse() ); + } + + # Give a notice if the user is uploading a file that has been deleted or moved + # Note that this is independent from the message 'filewasdeleted' + $desiredTitleObj = Title::makeTitleSafe( NS_FILE, $this->mDesiredDestName ); + $delNotice = ''; // empty by default + if ( $desiredTitleObj instanceof Title && !$desiredTitleObj->exists() ) { + $dbr = wfGetDB( DB_REPLICA ); + + LogEventsList::showLogExtract( $delNotice, [ 'delete', 'move' ], + $desiredTitleObj, + '', [ 'lim' => 10, + 'conds' => [ 'log_action != ' . $dbr->addQuotes( 'revision' ) ], + 'showIfEmpty' => false, + 'msgKey' => [ 'upload-recreate-warning' ] ] + ); + } + $form->addPreText( $delNotice ); + + # Add text to form + $form->addPreText( '<div id="uploadtext">' . + $this->msg( 'uploadtext', [ $this->mDesiredDestName ] )->parseAsBlock() . + '</div>' ); + # Add upload error message + $form->addPreText( $message ); + + # Add footer to form + $uploadFooter = $this->msg( 'uploadfooter' ); + if ( !$uploadFooter->isDisabled() ) { + $form->addPostText( '<div id="mw-upload-footer-message">' + . $uploadFooter->parseAsBlock() . "</div>\n" ); + } + + return $form; + } + + /** + * Shows the "view X deleted revivions link"" + */ + protected function showViewDeletedLinks() { + $title = Title::makeTitleSafe( NS_FILE, $this->mDesiredDestName ); + $user = $this->getUser(); + // Show a subtitle link to deleted revisions (to sysops et al only) + if ( $title instanceof Title ) { + $count = $title->isDeleted(); + if ( $count > 0 && $user->isAllowed( 'deletedhistory' ) ) { + $restorelink = $this->getLinkRenderer()->makeKnownLink( + SpecialPage::getTitleFor( 'Undelete', $title->getPrefixedText() ), + $this->msg( 'restorelink' )->numParams( $count )->text() + ); + $link = $this->msg( $user->isAllowed( 'delete' ) ? 'thisisdeleted' : 'viewdeleted' ) + ->rawParams( $restorelink )->parseAsBlock(); + $this->getOutput()->addHTML( "<div id=\"contentSub2\">{$link}</div>" ); + } + } + } + + /** + * Stashes the upload and shows the main upload form. + * + * Note: only errors that can be handled by changing the name or + * description should be redirected here. It should be assumed that the + * file itself is sane and has passed UploadBase::verifyFile. This + * essentially means that UploadBase::VERIFICATION_ERROR and + * UploadBase::EMPTY_FILE should not be passed here. + * + * @param string $message HTML message to be passed to mainUploadForm + */ + protected function showRecoverableUploadError( $message ) { + $stashStatus = $this->mUpload->tryStashFile( $this->getUser() ); + if ( $stashStatus->isGood() ) { + $sessionKey = $stashStatus->getValue()->getFileKey(); + $uploadWarning = 'upload-tryagain'; + } else { + $sessionKey = null; + $uploadWarning = 'upload-tryagain-nostash'; + } + $message = '<h2>' . $this->msg( 'uploaderror' )->escaped() . "</h2>\n" . + '<div class="error">' . $message . "</div>\n"; + + $form = $this->getUploadForm( $message, $sessionKey ); + $form->setSubmitText( $this->msg( $uploadWarning )->escaped() ); + $this->showUploadForm( $form ); + } + + /** + * Stashes the upload, shows the main form, but adds a "continue anyway button". + * Also checks whether there are actually warnings to display. + * + * @param array $warnings + * @return bool True if warnings were displayed, false if there are no + * warnings and it should continue processing + */ + protected function showUploadWarning( $warnings ) { + # If there are no warnings, or warnings we can ignore, return early. + # mDestWarningAck is set when some javascript has shown the warning + # to the user. mForReUpload is set when the user clicks the "upload a + # new version" link. + if ( !$warnings || ( count( $warnings ) == 1 + && isset( $warnings['exists'] ) + && ( $this->mDestWarningAck || $this->mForReUpload ) ) + ) { + return false; + } + + $stashStatus = $this->mUpload->tryStashFile( $this->getUser() ); + if ( $stashStatus->isGood() ) { + $sessionKey = $stashStatus->getValue()->getFileKey(); + $uploadWarning = 'uploadwarning-text'; + } else { + $sessionKey = null; + $uploadWarning = 'uploadwarning-text-nostash'; + } + + // Add styles for the warning, reused from the live preview + $this->getOutput()->addModuleStyles( 'mediawiki.special.upload.styles' ); + + $linkRenderer = $this->getLinkRenderer(); + $warningHtml = '<h2>' . $this->msg( 'uploadwarning' )->escaped() . "</h2>\n" + . '<div class="mw-destfile-warning"><ul>'; + foreach ( $warnings as $warning => $args ) { + if ( $warning == 'badfilename' ) { + $this->mDesiredDestName = Title::makeTitle( NS_FILE, $args )->getText(); + } + if ( $warning == 'exists' ) { + $msg = "\t<li>" . self::getExistsWarning( $args ) . "</li>\n"; + } elseif ( $warning == 'no-change' ) { + $file = $args; + $filename = $file->getTitle()->getPrefixedText(); + $msg = "\t<li>" . wfMessage( 'fileexists-no-change', $filename )->parse() . "</li>\n"; + } elseif ( $warning == 'duplicate-version' ) { + $file = $args[0]; + $count = count( $args ); + $filename = $file->getTitle()->getPrefixedText(); + $message = wfMessage( 'fileexists-duplicate-version' ) + ->params( $filename ) + ->numParams( $count ); + $msg = "\t<li>" . $message->parse() . "</li>\n"; + } elseif ( $warning == 'was-deleted' ) { + # If the file existed before and was deleted, warn the user of this + $ltitle = SpecialPage::getTitleFor( 'Log' ); + $llink = $linkRenderer->makeKnownLink( + $ltitle, + wfMessage( 'deletionlog' )->text(), + [], + [ + 'type' => 'delete', + 'page' => Title::makeTitle( NS_FILE, $args )->getPrefixedText(), + ] + ); + $msg = "\t<li>" . wfMessage( 'filewasdeleted' )->rawParams( $llink )->parse() . "</li>\n"; + } elseif ( $warning == 'duplicate' ) { + $msg = $this->getDupeWarning( $args ); + } elseif ( $warning == 'duplicate-archive' ) { + if ( $args === '' ) { + $msg = "\t<li>" . $this->msg( 'file-deleted-duplicate-notitle' )->parse() + . "</li>\n"; + } else { + $msg = "\t<li>" . $this->msg( 'file-deleted-duplicate', + Title::makeTitle( NS_FILE, $args )->getPrefixedText() )->parse() + . "</li>\n"; + } + } else { + if ( $args === true ) { + $args = []; + } elseif ( !is_array( $args ) ) { + $args = [ $args ]; + } + $msg = "\t<li>" . $this->msg( $warning, $args )->parse() . "</li>\n"; + } + $warningHtml .= $msg; + } + $warningHtml .= "</ul></div>\n"; + $warningHtml .= $this->msg( $uploadWarning )->parseAsBlock(); + + $form = $this->getUploadForm( $warningHtml, $sessionKey, /* $hideIgnoreWarning */ true ); + $form->setSubmitText( $this->msg( 'upload-tryagain' )->text() ); + $form->addButton( [ + 'name' => 'wpUploadIgnoreWarning', + 'value' => $this->msg( 'ignorewarning' )->text() + ] ); + $form->addButton( [ + 'name' => 'wpCancelUpload', + 'value' => $this->msg( 'reuploaddesc' )->text() + ] ); + + $this->showUploadForm( $form ); + + # Indicate that we showed a form + return true; + } + + /** + * Show the upload form with error message, but do not stash the file. + * + * @param string $message HTML string + */ + protected function showUploadError( $message ) { + $message = '<h2>' . $this->msg( 'uploadwarning' )->escaped() . "</h2>\n" . + '<div class="error">' . $message . "</div>\n"; + $this->showUploadForm( $this->getUploadForm( $message ) ); + } + + /** + * Do the upload. + * Checks are made in SpecialUpload::execute() + */ + protected function processUpload() { + // Fetch the file if required + $status = $this->mUpload->fetchFile(); + if ( !$status->isOK() ) { + $this->showUploadError( $this->getOutput()->parse( $status->getWikiText() ) ); + + return; + } + // Avoid PHP 7.1 warning of passing $this by reference + $upload = $this; + if ( !Hooks::run( 'UploadForm:BeforeProcessing', [ &$upload ] ) ) { + wfDebug( "Hook 'UploadForm:BeforeProcessing' broke processing the file.\n" ); + // This code path is deprecated. If you want to break upload processing + // do so by hooking into the appropriate hooks in UploadBase::verifyUpload + // and UploadBase::verifyFile. + // If you use this hook to break uploading, the user will be returned + // an empty form with no error message whatsoever. + return; + } + + // Upload verification + $details = $this->mUpload->verifyUpload(); + if ( $details['status'] != UploadBase::OK ) { + $this->processVerificationError( $details ); + + return; + } + + // Verify permissions for this title + $permErrors = $this->mUpload->verifyTitlePermissions( $this->getUser() ); + if ( $permErrors !== true ) { + $code = array_shift( $permErrors[0] ); + $this->showRecoverableUploadError( $this->msg( $code, $permErrors[0] )->parse() ); + + return; + } + + $this->mLocalFile = $this->mUpload->getLocalFile(); + + // Check warnings if necessary + if ( !$this->mIgnoreWarning ) { + $warnings = $this->mUpload->checkWarnings(); + if ( $this->showUploadWarning( $warnings ) ) { + return; + } + } + + // This is as late as we can throttle, after expected issues have been handled + if ( UploadBase::isThrottled( $this->getUser() ) ) { + $this->showRecoverableUploadError( + $this->msg( 'actionthrottledtext' )->escaped() + ); + return; + } + + // Get the page text if this is not a reupload + if ( !$this->mForReUpload ) { + $pageText = self::getInitialPageText( $this->mComment, $this->mLicense, + $this->mCopyrightStatus, $this->mCopyrightSource, $this->getConfig() ); + } else { + $pageText = false; + } + + $changeTags = $this->getRequest()->getVal( 'wpChangeTags' ); + if ( is_null( $changeTags ) || $changeTags === '' ) { + $changeTags = []; + } else { + $changeTags = array_filter( array_map( 'trim', explode( ',', $changeTags ) ) ); + } + + if ( $changeTags ) { + $changeTagsStatus = ChangeTags::canAddTagsAccompanyingChange( + $changeTags, $this->getUser() ); + if ( !$changeTagsStatus->isOK() ) { + $this->showUploadError( $this->getOutput()->parse( $changeTagsStatus->getWikiText() ) ); + + return; + } + } + + $status = $this->mUpload->performUpload( + $this->mComment, + $pageText, + $this->mWatchthis, + $this->getUser(), + $changeTags + ); + + if ( !$status->isGood() ) { + $this->showRecoverableUploadError( $this->getOutput()->parse( $status->getWikiText() ) ); + + return; + } + + // Success, redirect to description page + $this->mUploadSuccessful = true; + // Avoid PHP 7.1 warning of passing $this by reference + $upload = $this; + Hooks::run( 'SpecialUploadComplete', [ &$upload ] ); + $this->getOutput()->redirect( $this->mLocalFile->getTitle()->getFullURL() ); + } + + /** + * Get the initial image page text based on a comment and optional file status information + * @param string $comment + * @param string $license + * @param string $copyStatus + * @param string $source + * @param Config $config Configuration object to load data from + * @return string + */ + public static function getInitialPageText( $comment = '', $license = '', + $copyStatus = '', $source = '', Config $config = null + ) { + if ( $config === null ) { + wfDebug( __METHOD__ . ' called without a Config instance passed to it' ); + $config = MediaWikiServices::getInstance()->getMainConfig(); + } + + $msg = []; + $forceUIMsgAsContentMsg = (array)$config->get( 'ForceUIMsgAsContentMsg' ); + /* These messages are transcluded into the actual text of the description page. + * Thus, forcing them as content messages makes the upload to produce an int: template + * instead of hardcoding it there in the uploader language. + */ + foreach ( [ 'license-header', 'filedesc', 'filestatus', 'filesource' ] as $msgName ) { + if ( in_array( $msgName, $forceUIMsgAsContentMsg ) ) { + $msg[$msgName] = "{{int:$msgName}}"; + } else { + $msg[$msgName] = wfMessage( $msgName )->inContentLanguage()->text(); + } + } + + $licenseText = ''; + if ( $license !== '' ) { + $licenseText = '== ' . $msg['license-header'] . " ==\n{{" . $license . "}}\n"; + } + + $pageText = $comment . "\n"; + $headerText = '== ' . $msg['filedesc'] . ' =='; + if ( $comment !== '' && strpos( $comment, $headerText ) === false ) { + // prepend header to page text unless it's already there (or there is no content) + $pageText = $headerText . "\n" . $pageText; + } + + if ( $config->get( 'UseCopyrightUpload' ) ) { + $pageText .= '== ' . $msg['filestatus'] . " ==\n" . $copyStatus . "\n"; + $pageText .= $licenseText; + $pageText .= '== ' . $msg['filesource'] . " ==\n" . $source; + } else { + $pageText .= $licenseText; + } + + // allow extensions to modify the content + Hooks::run( 'UploadForm:getInitialPageText', [ &$pageText, $msg, $config ] ); + + return $pageText; + } + + /** + * See if we should check the 'watch this page' checkbox on the form + * based on the user's preferences and whether we're being asked + * to create a new file or update an existing one. + * + * In the case where 'watch edits' is off but 'watch creations' is on, + * we'll leave the box unchecked. + * + * Note that the page target can be changed *on the form*, so our check + * state can get out of sync. + * @return bool|string + */ + protected function getWatchCheck() { + if ( $this->getUser()->getOption( 'watchdefault' ) ) { + // Watch all edits! + return true; + } + + $desiredTitleObj = Title::makeTitleSafe( NS_FILE, $this->mDesiredDestName ); + if ( $desiredTitleObj instanceof Title && $this->getUser()->isWatched( $desiredTitleObj ) ) { + // Already watched, don't change that + return true; + } + + $local = wfLocalFile( $this->mDesiredDestName ); + if ( $local && $local->exists() ) { + // We're uploading a new version of an existing file. + // No creation, so don't watch it if we're not already. + return false; + } else { + // New page should get watched if that's our option. + return $this->getUser()->getOption( 'watchcreations' ) || + $this->getUser()->getOption( 'watchuploads' ); + } + } + + /** + * Provides output to the user for a result of UploadBase::verifyUpload + * + * @param array $details Result of UploadBase::verifyUpload + * @throws MWException + */ + protected function processVerificationError( $details ) { + switch ( $details['status'] ) { + /** Statuses that only require name changing **/ + case UploadBase::MIN_LENGTH_PARTNAME: + $this->showRecoverableUploadError( $this->msg( 'minlength1' )->escaped() ); + break; + case UploadBase::ILLEGAL_FILENAME: + $this->showRecoverableUploadError( $this->msg( 'illegalfilename', + $details['filtered'] )->parse() ); + break; + case UploadBase::FILENAME_TOO_LONG: + $this->showRecoverableUploadError( $this->msg( 'filename-toolong' )->escaped() ); + break; + case UploadBase::FILETYPE_MISSING: + $this->showRecoverableUploadError( $this->msg( 'filetype-missing' )->parse() ); + break; + case UploadBase::WINDOWS_NONASCII_FILENAME: + $this->showRecoverableUploadError( $this->msg( 'windows-nonascii-filename' )->parse() ); + break; + + /** Statuses that require reuploading **/ + case UploadBase::EMPTY_FILE: + $this->showUploadError( $this->msg( 'emptyfile' )->escaped() ); + break; + case UploadBase::FILE_TOO_LARGE: + $this->showUploadError( $this->msg( 'largefileserver' )->escaped() ); + break; + case UploadBase::FILETYPE_BADTYPE: + $msg = $this->msg( 'filetype-banned-type' ); + if ( isset( $details['blacklistedExt'] ) ) { + $msg->params( $this->getLanguage()->commaList( $details['blacklistedExt'] ) ); + } else { + $msg->params( $details['finalExt'] ); + } + $extensions = array_unique( $this->getConfig()->get( 'FileExtensions' ) ); + $msg->params( $this->getLanguage()->commaList( $extensions ), + count( $extensions ) ); + + // Add PLURAL support for the first parameter. This results + // in a bit unlogical parameter sequence, but does not break + // old translations + if ( isset( $details['blacklistedExt'] ) ) { + $msg->params( count( $details['blacklistedExt'] ) ); + } else { + $msg->params( 1 ); + } + + $this->showUploadError( $msg->parse() ); + break; + case UploadBase::VERIFICATION_ERROR: + unset( $details['status'] ); + $code = array_shift( $details['details'] ); + $this->showUploadError( $this->msg( $code, $details['details'] )->parse() ); + break; + case UploadBase::HOOK_ABORTED: + if ( is_array( $details['error'] ) ) { # allow hooks to return error details in an array + $args = $details['error']; + $error = array_shift( $args ); + } else { + $error = $details['error']; + $args = null; + } + + $this->showUploadError( $this->msg( $error, $args )->parse() ); + break; + default: + throw new MWException( __METHOD__ . ": Unknown value `{$details['status']}`" ); + } + } + + /** + * Remove a temporarily kept file stashed by saveTempUploadedFile(). + * + * @return bool Success + */ + protected function unsaveUploadedFile() { + if ( !( $this->mUpload instanceof UploadFromStash ) ) { + return true; + } + $success = $this->mUpload->unsaveUploadedFile(); + if ( !$success ) { + $this->getOutput()->showFileDeleteError( $this->mUpload->getTempPath() ); + + return false; + } else { + return true; + } + } + + /*** Functions for formatting warnings ***/ + + /** + * Formats a result of UploadBase::getExistsWarning as HTML + * This check is static and can be done pre-upload via AJAX + * + * @param array $exists The result of UploadBase::getExistsWarning + * @return string Empty string if there is no warning or an HTML fragment + */ + public static function getExistsWarning( $exists ) { + if ( !$exists ) { + return ''; + } + + $file = $exists['file']; + $filename = $file->getTitle()->getPrefixedText(); + $warnMsg = null; + + if ( $exists['warning'] == 'exists' ) { + // Exact match + $warnMsg = wfMessage( 'fileexists', $filename ); + } elseif ( $exists['warning'] == 'page-exists' ) { + // Page exists but file does not + $warnMsg = wfMessage( 'filepageexists', $filename ); + } elseif ( $exists['warning'] == 'exists-normalized' ) { + $warnMsg = wfMessage( 'fileexists-extension', $filename, + $exists['normalizedFile']->getTitle()->getPrefixedText() ); + } elseif ( $exists['warning'] == 'thumb' ) { + // Swapped argument order compared with other messages for backwards compatibility + $warnMsg = wfMessage( 'fileexists-thumbnail-yes', + $exists['thumbFile']->getTitle()->getPrefixedText(), $filename ); + } elseif ( $exists['warning'] == 'thumb-name' ) { + // Image w/o '180px-' does not exists, but we do not like these filenames + $name = $file->getName(); + $badPart = substr( $name, 0, strpos( $name, '-' ) + 1 ); + $warnMsg = wfMessage( 'file-thumbnail-no', $badPart ); + } elseif ( $exists['warning'] == 'bad-prefix' ) { + $warnMsg = wfMessage( 'filename-bad-prefix', $exists['prefix'] ); + } + + return $warnMsg ? $warnMsg->title( $file->getTitle() )->parse() : ''; + } + + /** + * Construct a warning and a gallery from an array of duplicate files. + * @param array $dupes + * @return string + */ + public function getDupeWarning( $dupes ) { + if ( !$dupes ) { + return ''; + } + + $gallery = ImageGalleryBase::factory( false, $this->getContext() ); + $gallery->setShowBytes( false ); + $gallery->setShowDimensions( false ); + foreach ( $dupes as $file ) { + $gallery->add( $file->getTitle() ); + } + + return '<li>' . + $this->msg( 'file-exists-duplicate' )->numParams( count( $dupes ) )->parse() . + $gallery->toHTML() . "</li>\n"; + } + + protected function getGroupName() { + return 'media'; + } + + /** + * Should we rotate images in the preview on Special:Upload. + * + * This controls js: mw.config.get( 'wgFileCanRotate' ) + * + * @todo What about non-BitmapHandler handled files? + * @return bool + */ + public static function rotationEnabled() { + $bitmapHandler = new BitmapHandler(); + return $bitmapHandler->autoRotateEnabled(); + } +} diff --git a/www/wiki/includes/specials/SpecialUploadStash.php b/www/wiki/includes/specials/SpecialUploadStash.php new file mode 100644 index 00000000..c8b1578f --- /dev/null +++ b/www/wiki/includes/specials/SpecialUploadStash.php @@ -0,0 +1,456 @@ +<?php +/** + * Implements Special:UploadStash. + * + * 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 + */ + +/** + * Web access for files temporarily stored by UploadStash. + * + * For example -- files that were uploaded with the UploadWizard extension are stored temporarily + * before committing them to the db. But we want to see their thumbnails and get other information + * about them. + * + * Since this is based on the user's session, in effect this creates a private temporary file area. + * However, the URLs for the files cannot be shared. + * + * @ingroup SpecialPage + * @ingroup Upload + */ +class SpecialUploadStash extends UnlistedSpecialPage { + // UploadStash + private $stash; + + /** + * Since we are directly writing the file to STDOUT, + * we should not be reading in really big files and serving them out. + * + * We also don't want people using this as a file drop, even if they + * share credentials. + * + * This service is really for thumbnails and other such previews while + * uploading. + */ + const MAX_SERVE_BYTES = 1048576; // 1MB + + public function __construct() { + parent::__construct( 'UploadStash', 'upload' ); + } + + public function doesWrites() { + return true; + } + + /** + * Execute page -- can output a file directly or show a listing of them. + * + * @param string $subPage Subpage, e.g. in + * https://example.com/wiki/Special:UploadStash/foo.jpg, the "foo.jpg" part + * @return bool Success + */ + public function execute( $subPage ) { + $this->useTransactionalTimeLimit(); + + $this->stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $this->getUser() ); + $this->checkPermissions(); + + if ( $subPage === null || $subPage === '' ) { + return $this->showUploads(); + } + + return $this->showUpload( $subPage ); + } + + /** + * If file available in stash, cats it out to the client as a simple HTTP response. + * n.b. Most sanity checking done in UploadStashLocalFile, so this is straightforward. + * + * @param string $key The key of a particular requested file + * @throws HttpError + * @return bool + */ + public function showUpload( $key ) { + // prevent callers from doing standard HTML output -- we'll take it from here + $this->getOutput()->disable(); + + try { + $params = $this->parseKey( $key ); + if ( $params['type'] === 'thumb' ) { + return $this->outputThumbFromStash( $params['file'], $params['params'] ); + } else { + return $this->outputLocalFile( $params['file'] ); + } + } catch ( UploadStashFileNotFoundException $e ) { + $code = 404; + $message = $e->getMessage(); + } catch ( UploadStashZeroLengthFileException $e ) { + $code = 500; + $message = $e->getMessage(); + } catch ( UploadStashBadPathException $e ) { + $code = 500; + $message = $e->getMessage(); + } catch ( SpecialUploadStashTooLargeException $e ) { + $code = 500; + $message = $e->getMessage(); + } catch ( Exception $e ) { + $code = 500; + $message = $e->getMessage(); + } + + throw new HttpError( $code, $message ); + } + + /** + * Parse the key passed to the SpecialPage. Returns an array containing + * the associated file object, the type ('file' or 'thumb') and if + * application the transform parameters + * + * @param string $key + * @throws UploadStashBadPathException + * @return array + */ + private function parseKey( $key ) { + $type = strtok( $key, '/' ); + + if ( $type !== 'file' && $type !== 'thumb' ) { + throw new UploadStashBadPathException( + wfMessage( 'uploadstash-bad-path-unknown-type', $type ) + ); + } + $fileName = strtok( '/' ); + $thumbPart = strtok( '/' ); + $file = $this->stash->getFile( $fileName ); + if ( $type === 'thumb' ) { + $srcNamePos = strrpos( $thumbPart, $fileName ); + if ( $srcNamePos === false || $srcNamePos < 1 ) { + throw new UploadStashBadPathException( + wfMessage( 'uploadstash-bad-path-unrecognized-thumb-name' ) + ); + } + $paramString = substr( $thumbPart, 0, $srcNamePos - 1 ); + + $handler = $file->getHandler(); + if ( $handler ) { + $params = $handler->parseParamString( $paramString ); + + return [ 'file' => $file, 'type' => $type, 'params' => $params ]; + } else { + throw new UploadStashBadPathException( + wfMessage( 'uploadstash-bad-path-no-handler', $file->getMimeType(), $file->getPath() ) + ); + } + } + + return [ 'file' => $file, 'type' => $type ]; + } + + /** + * Get a thumbnail for file, either generated locally or remotely, and stream it out + * + * @param File $file + * @param array $params + * + * @return bool Success + */ + private function outputThumbFromStash( $file, $params ) { + $flags = 0; + // this config option, if it exists, points to a "scaler", as you might find in + // the Wikimedia Foundation cluster. See outputRemoteScaledThumb(). This + // is part of our horrible NFS-based system, we create a file on a mount + // point here, but fetch the scaled file from somewhere else that + // happens to share it over NFS. + if ( $this->getConfig()->get( 'UploadStashScalerBaseUrl' ) ) { + $this->outputRemoteScaledThumb( $file, $params, $flags ); + } else { + $this->outputLocallyScaledThumb( $file, $params, $flags ); + } + } + + /** + * Scale a file (probably with a locally installed imagemagick, or similar) + * and output it to STDOUT. + * @param File $file + * @param array $params Scaling parameters ( e.g. [ width => '50' ] ); + * @param int $flags Scaling flags ( see File:: constants ) + * @throws MWException|UploadStashFileNotFoundException + * @return bool Success + */ + private function outputLocallyScaledThumb( $file, $params, $flags ) { + // n.b. this is stupid, we insist on re-transforming the file every time we are invoked. We rely + // on HTTP caching to ensure this doesn't happen. + + $flags |= File::RENDER_NOW; + + $thumbnailImage = $file->transform( $params, $flags ); + if ( !$thumbnailImage ) { + throw new UploadStashFileNotFoundException( + wfMessage( 'uploadstash-file-not-found-no-thumb' ) + ); + } + + // we should have just generated it locally + if ( !$thumbnailImage->getStoragePath() ) { + throw new UploadStashFileNotFoundException( + wfMessage( 'uploadstash-file-not-found-no-local-path' ) + ); + } + + // now we should construct a File, so we can get MIME and other such info in a standard way + // n.b. MIME type may be different from original (ogx original -> jpeg thumb) + $thumbFile = new UnregisteredLocalFile( false, + $this->stash->repo, $thumbnailImage->getStoragePath(), false ); + if ( !$thumbFile ) { + throw new UploadStashFileNotFoundException( + wfMessage( 'uploadstash-file-not-found-no-object' ) + ); + } + + return $this->outputLocalFile( $thumbFile ); + } + + /** + * Scale a file with a remote "scaler", as exists on the Wikimedia Foundation + * cluster, and output it to STDOUT. + * Note: Unlike the usual thumbnail process, the web client never sees the + * cluster URL; we do the whole HTTP transaction to the scaler ourselves + * and cat the results out. + * Note: We rely on NFS to have propagated the file contents to the scaler. + * However, we do not rely on the thumbnail being created in NFS and then + * propagated back to our filesystem. Instead we take the results of the + * HTTP request instead. + * Note: No caching is being done here, although we are instructing the + * client to cache it forever. + * + * @param File $file + * @param array $params Scaling parameters ( e.g. [ width => '50' ] ); + * @param int $flags Scaling flags ( see File:: constants ) + * @throws MWException + * @return bool Success + */ + private function outputRemoteScaledThumb( $file, $params, $flags ) { + // This option probably looks something like + // '//upload.wikimedia.org/wikipedia/test/thumb/temp'. Do not use + // trailing slash. + $scalerBaseUrl = $this->getConfig()->get( 'UploadStashScalerBaseUrl' ); + + if ( preg_match( '/^\/\//', $scalerBaseUrl ) ) { + // this is apparently a protocol-relative URL, which makes no sense in this context, + // since this is used for communication that's internal to the application. + // default to http. + $scalerBaseUrl = wfExpandUrl( $scalerBaseUrl, PROTO_CANONICAL ); + } + + // We need to use generateThumbName() instead of thumbName(), because + // the suffix needs to match the file name for the remote thumbnailer + // to work + $scalerThumbName = $file->generateThumbName( $file->getName(), $params ); + $scalerThumbUrl = $scalerBaseUrl . '/' . $file->getUrlRel() . + '/' . rawurlencode( $scalerThumbName ); + + // make a curl call to the scaler to create a thumbnail + $httpOptions = [ + 'method' => 'GET', + 'timeout' => 5 // T90599 attempt to time out cleanly + ]; + $req = MWHttpRequest::factory( $scalerThumbUrl, $httpOptions, __METHOD__ ); + $status = $req->execute(); + if ( !$status->isOK() ) { + $errors = $status->getErrorsArray(); + throw new UploadStashFileNotFoundException( + wfMessage( + 'uploadstash-file-not-found-no-remote-thumb', + print_r( $errors, 1 ), + $scalerThumbUrl + ) + ); + } + $contentType = $req->getResponseHeader( "content-type" ); + if ( !$contentType ) { + throw new UploadStashFileNotFoundException( + wfMessage( 'uploadstash-file-not-found-missing-content-type' ) + ); + } + + return $this->outputContents( $req->getContent(), $contentType ); + } + + /** + * Output HTTP response for file + * Side effect: writes HTTP response to STDOUT. + * + * @param File $file File object with a local path (e.g. UnregisteredLocalFile, + * LocalFile. Oddly these don't share an ancestor!) + * @throws SpecialUploadStashTooLargeException + * @return bool + */ + private function outputLocalFile( File $file ) { + if ( $file->getSize() > self::MAX_SERVE_BYTES ) { + throw new SpecialUploadStashTooLargeException( + wfMessage( 'uploadstash-file-too-large', self::MAX_SERVE_BYTES ) + ); + } + + return $file->getRepo()->streamFile( $file->getPath(), + [ 'Content-Transfer-Encoding: binary', + 'Expires: Sun, 17-Jan-2038 19:14:07 GMT' ] + ); + } + + /** + * Output HTTP response of raw content + * Side effect: writes HTTP response to STDOUT. + * @param string $content + * @param string $contentType MIME type + * @throws SpecialUploadStashTooLargeException + * @return bool + */ + private function outputContents( $content, $contentType ) { + $size = strlen( $content ); + if ( $size > self::MAX_SERVE_BYTES ) { + throw new SpecialUploadStashTooLargeException( + wfMessage( 'uploadstash-file-too-large', self::MAX_SERVE_BYTES ) + ); + } + // Cancel output buffering and gzipping if set + wfResetOutputBuffers(); + self::outputFileHeaders( $contentType, $size ); + print $content; + + return true; + } + + /** + * Output headers for streaming + * @todo Unsure about encoding as binary; if we received from HTTP perhaps + * we should use that encoding, concatenated with semicolon to `$contentType` as it + * usually is. + * Side effect: preps PHP to write headers to STDOUT. + * @param string $contentType String suitable for content-type header + * @param string $size Length in bytes + */ + private static function outputFileHeaders( $contentType, $size ) { + header( "Content-Type: $contentType", true ); + header( 'Content-Transfer-Encoding: binary', true ); + header( 'Expires: Sun, 17-Jan-2038 19:14:07 GMT', true ); + // T55032 - It shouldn't be a problem here, but let's be safe and not cache + header( 'Cache-Control: private' ); + header( "Content-Length: $size", true ); + } + + /** + * Static callback for the HTMLForm in showUploads, to process + * Note the stash has to be recreated since this is being called in a static context. + * This works, because there really is only one stash per logged-in user, despite appearances. + * + * @param array $formData + * @param HTMLForm $form + * @return Status + */ + public static function tryClearStashedUploads( $formData, $form ) { + if ( isset( $formData['Clear'] ) ) { + $stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $form->getUser() ); + wfDebug( 'stash has: ' . print_r( $stash->listFiles(), true ) . "\n" ); + + if ( !$stash->clear() ) { + return Status::newFatal( 'uploadstash-errclear' ); + } + } + + return Status::newGood(); + } + + /** + * Default action when we don't have a subpage -- just show links to the uploads we have, + * Also show a button to clear stashed files + * @return bool + */ + private function showUploads() { + // sets the title, etc. + $this->setHeaders(); + $this->outputHeader(); + + // create the form, which will also be used to execute a callback to process incoming form data + // this design is extremely dubious, but supposedly HTMLForm is our standard now? + + $context = new DerivativeContext( $this->getContext() ); + $context->setTitle( $this->getPageTitle() ); // Remove subpage + $form = HTMLForm::factory( 'ooui', [ + 'Clear' => [ + 'type' => 'hidden', + 'default' => true, + 'name' => 'clear', + ] + ], $context, 'clearStashedUploads' ); + $form->setSubmitDestructive(); + $form->setSubmitCallback( [ __CLASS__, 'tryClearStashedUploads' ] ); + $form->setSubmitTextMsg( 'uploadstash-clear' ); + + $form->prepareForm(); + $formResult = $form->tryAuthorizedSubmit(); + + // show the files + form, if there are any, or just say there are none + $refreshHtml = Html::element( 'a', + [ 'href' => $this->getPageTitle()->getLocalURL() ], + $this->msg( 'uploadstash-refresh' )->text() ); + $files = $this->stash->listFiles(); + if ( $files && count( $files ) ) { + sort( $files ); + $fileListItemsHtml = ''; + $linkRenderer = $this->getLinkRenderer(); + foreach ( $files as $file ) { + $itemHtml = $linkRenderer->makeKnownLink( + $this->getPageTitle( "file/$file" ), + $file + ); + try { + $fileObj = $this->stash->getFile( $file ); + $thumb = $fileObj->generateThumbName( $file, [ 'width' => 220 ] ); + $itemHtml .= + $this->msg( 'word-separator' )->escaped() . + $this->msg( 'parentheses' )->rawParams( + $linkRenderer->makeKnownLink( + $this->getPageTitle( "thumb/$file/$thumb" ), + $this->msg( 'uploadstash-thumbnail' )->text() + ) + )->escaped(); + } catch ( Exception $e ) { + } + $fileListItemsHtml .= Html::rawElement( 'li', [], $itemHtml ); + } + $this->getOutput()->addHTML( Html::rawElement( 'ul', [], $fileListItemsHtml ) ); + $form->displayForm( $formResult ); + $this->getOutput()->addHTML( Html::rawElement( 'p', [], $refreshHtml ) ); + } else { + $this->getOutput()->addHTML( Html::rawElement( 'p', [], + Html::element( 'span', [], $this->msg( 'uploadstash-nofiles' )->text() ) + . ' ' + . $refreshHtml + ) ); + } + + return true; + } +} + +/** + * @ingroup SpecialPage + * @ingroup Upload + */ +class SpecialUploadStashTooLargeException extends UploadStashException { +} diff --git a/www/wiki/includes/specials/SpecialUserLogin.php b/www/wiki/includes/specials/SpecialUserLogin.php new file mode 100644 index 00000000..253cd507 --- /dev/null +++ b/www/wiki/includes/specials/SpecialUserLogin.php @@ -0,0 +1,162 @@ +<?php +/** + * Implements Special:UserLogin + * + * 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 + */ + +use MediaWiki\Auth\AuthManager; +use MediaWiki\Logger\LoggerFactory; + +/** + * Implements Special:UserLogin + * + * @ingroup SpecialPage + */ +class SpecialUserLogin extends LoginSignupSpecialPage { + protected static $allowedActions = [ + AuthManager::ACTION_LOGIN, + AuthManager::ACTION_LOGIN_CONTINUE + ]; + + protected static $messages = [ + 'authform-newtoken' => 'nocookiesforlogin', + 'authform-notoken' => 'sessionfailure', + 'authform-wrongtoken' => 'sessionfailure', + ]; + + public function __construct() { + parent::__construct( 'Userlogin' ); + } + + public function doesWrites() { + return true; + } + + protected function getLoginSecurityLevel() { + return false; + } + + protected function getDefaultAction( $subPage ) { + return AuthManager::ACTION_LOGIN; + } + + public function getDescription() { + return $this->msg( 'login' )->text(); + } + + public function setHeaders() { + // override the page title if we are doing a forced reauthentication + parent::setHeaders(); + if ( $this->securityLevel && $this->getUser()->isLoggedIn() ) { + $this->getOutput()->setPageTitle( $this->msg( 'login-security' ) ); + } + } + + protected function isSignup() { + return false; + } + + protected function beforeExecute( $subPage ) { + if ( $subPage === 'signup' || $this->getRequest()->getText( 'type' ) === 'signup' ) { + // B/C for old account creation URLs + $title = SpecialPage::getTitleFor( 'CreateAccount' ); + $query = array_diff_key( $this->getRequest()->getValues(), + array_fill_keys( [ 'type', 'title' ], true ) ); + $url = $title->getFullURL( $query, false, PROTO_CURRENT ); + $this->getOutput()->redirect( $url ); + return false; + } + return parent::beforeExecute( $subPage ); + } + + /** + * Run any hooks registered for logins, then HTTP redirect to + * $this->mReturnTo (or Main Page if that's undefined). Formerly we had a + * nice message here, but that's really not as useful as just being sent to + * wherever you logged in from. It should be clear that the action was + * successful, given the lack of error messages plus the appearance of your + * name in the upper right. + * @param bool $direct True if the action was successful just now; false if that happened + * pre-redirection (so this handler was called already) + * @param StatusValue|null $extraMessages + */ + protected function successfulAction( $direct = false, $extraMessages = null ) { + global $wgSecureLogin; + + $user = $this->targetUser ?: $this->getUser(); + $session = $this->getRequest()->getSession(); + + if ( $direct ) { + $user->touch(); + + $this->clearToken(); + + if ( $user->requiresHTTPS() ) { + $this->mStickHTTPS = true; + } + $session->setForceHTTPS( $wgSecureLogin && $this->mStickHTTPS ); + + // If the user does not have a session cookie at this point, they probably need to + // do something to their browser. + if ( !$this->hasSessionCookie() ) { + $this->mainLoginForm( [ /*?*/ ], $session->getProvider()->whyNoSession() ); + // TODO something more specific? This used to use nocookieslogin + return; + } + } + + # Run any hooks; display injected HTML if any, else redirect + $injected_html = ''; + Hooks::run( 'UserLoginComplete', [ &$user, &$injected_html, $direct ] ); + + if ( $injected_html !== '' || $extraMessages ) { + $this->showSuccessPage( 'success', $this->msg( 'loginsuccesstitle' ), + 'loginsuccess', $injected_html, $extraMessages ); + } else { + $helper = new LoginHelper( $this->getContext() ); + $helper->showReturnToPage( 'successredirect', $this->mReturnTo, $this->mReturnToQuery, + $this->mStickHTTPS ); + } + } + + protected function getToken() { + return $this->getRequest()->getSession()->getToken( '', 'login' ); + } + + protected function clearToken() { + return $this->getRequest()->getSession()->resetToken( 'login' ); + } + + protected function getTokenName() { + return 'wpLoginToken'; + } + + protected function getGroupName() { + return 'login'; + } + + protected function logAuthResult( $success, $status = null ) { + LoggerFactory::getInstance( 'authevents' )->info( 'Login attempt', [ + 'event' => 'login', + 'successful' => $success, + 'status' => $status, + ] ); + } +} diff --git a/www/wiki/includes/specials/SpecialUserLogout.php b/www/wiki/includes/specials/SpecialUserLogout.php new file mode 100644 index 00000000..568327d2 --- /dev/null +++ b/www/wiki/includes/specials/SpecialUserLogout.php @@ -0,0 +1,107 @@ +<?php +/** + * Implements Special:Userlogout + * + * 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 + */ + +/** + * Implements Special:Userlogout + * + * @ingroup SpecialPage + */ +class SpecialUserLogout extends UnlistedSpecialPage { + function __construct() { + parent::__construct( 'Userlogout' ); + } + + public function doesWrites() { + return true; + } + + function execute( $par ) { + /** + * Some satellite ISPs use broken precaching schemes that log people out straight after + * they're logged in (T19790). Luckily, there's a way to detect such requests. + */ + if ( isset( $_SERVER['REQUEST_URI'] ) && strpos( $_SERVER['REQUEST_URI'], '&' ) !== false ) { + wfDebug( "Special:UserLogout request {$_SERVER['REQUEST_URI']} looks suspicious, denying.\n" ); + throw new HttpError( 400, $this->msg( 'suspicious-userlogout' ), $this->msg( 'loginerror' ) ); + } + + $this->setHeaders(); + $this->outputHeader(); + + $out = $this->getOutput(); + $user = $this->getUser(); + $request = $this->getRequest(); + + $logoutToken = $request->getVal( 'logoutToken' ); + $urlParams = [ + 'logoutToken' => $user->getEditToken( 'logoutToken', $request ) + ] + $request->getValues(); + unset( $urlParams['title'] ); + $continueLink = $this->getFullTitle()->getFullUrl( $urlParams ); + + if ( $logoutToken === null ) { + $this->getOutput()->addWikiMsg( 'userlogout-continue', $continueLink ); + return; + } + if ( !$this->getUser()->matchEditToken( + $logoutToken, 'logoutToken', $this->getRequest(), 24 * 60 * 60 + ) ) { + $this->getOutput()->addWikiMsg( 'userlogout-sessionerror', $continueLink ); + return; + } + + // Make sure it's possible to log out + $session = MediaWiki\Session\SessionManager::getGlobalSession(); + if ( !$session->canSetUser() ) { + throw new ErrorPageError( + 'cannotlogoutnow-title', + 'cannotlogoutnow-text', + [ + $session->getProvider()->describe( RequestContext::getMain()->getLanguage() ) + ] + ); + } + + $user = $this->getUser(); + $oldName = $user->getName(); + + $user->logout(); + + $loginURL = SpecialPage::getTitleFor( 'Userlogin' )->getFullURL( + $this->getRequest()->getValues( 'returnto', 'returntoquery' ) ); + + $out = $this->getOutput(); + $out->addWikiMsg( 'logouttext', $loginURL ); + + // Hook. + $injected_html = ''; + Hooks::run( 'UserLogoutComplete', [ &$user, &$injected_html, $oldName ] ); + $out->addHTML( $injected_html ); + + $out->returnToMain(); + } + + protected function getGroupName() { + return 'login'; + } +} diff --git a/www/wiki/includes/specials/SpecialUserrights.php b/www/wiki/includes/specials/SpecialUserrights.php new file mode 100644 index 00000000..40f02a5f --- /dev/null +++ b/www/wiki/includes/specials/SpecialUserrights.php @@ -0,0 +1,1042 @@ +<?php +/** + * Implements Special:Userrights + * + * 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 + */ + +/** + * Special page to allow managing user group membership + * + * @ingroup SpecialPage + */ +class UserrightsPage extends SpecialPage { + /** + * The target of the local right-adjuster's interest. Can be gotten from + * either a GET parameter or a subpage-style parameter, so have a member + * variable for it. + * @var null|string $mTarget + */ + protected $mTarget; + /* + * @var null|User $mFetchedUser The user object of the target username or null. + */ + protected $mFetchedUser = null; + protected $isself = false; + + public function __construct() { + parent::__construct( 'Userrights' ); + } + + public function doesWrites() { + return true; + } + + /** + * Check whether the current user (from context) can change the target user's rights. + * + * @param User $targetUser User whose rights are being changed + * @param bool $checkIfSelf If false, assume that the current user can add/remove groups defined + * in $wgGroupsAddToSelf / $wgGroupsRemoveFromSelf, without checking if it's the same as target + * user + * @return bool + */ + public function userCanChangeRights( $targetUser, $checkIfSelf = true ) { + $isself = $this->getUser()->equals( $targetUser ); + + $available = $this->changeableGroups(); + if ( $targetUser->getId() == 0 ) { + return false; + } + + return !empty( $available['add'] ) + || !empty( $available['remove'] ) + || ( ( $isself || !$checkIfSelf ) && + ( !empty( $available['add-self'] ) + || !empty( $available['remove-self'] ) ) ); + } + + /** + * Manage forms to be shown according to posted data. + * Depending on the submit button used, call a form or a save function. + * + * @param string|null $par String if any subpage provided, else null + * @throws UserBlockedError|PermissionsError + */ + public function execute( $par ) { + $user = $this->getUser(); + $request = $this->getRequest(); + $session = $request->getSession(); + $out = $this->getOutput(); + + $out->addModules( [ 'mediawiki.special.userrights' ] ); + + if ( $par !== null ) { + $this->mTarget = $par; + } else { + $this->mTarget = $request->getVal( 'user' ); + } + + if ( is_string( $this->mTarget ) ) { + $this->mTarget = trim( $this->mTarget ); + } + + if ( $this->mTarget !== null && User::getCanonicalName( $this->mTarget ) === $user->getName() ) { + $this->isself = true; + } + + $fetchedStatus = $this->fetchUser( $this->mTarget, true ); + if ( $fetchedStatus->isOK() ) { + $this->mFetchedUser = $fetchedStatus->value; + if ( $this->mFetchedUser instanceof User ) { + // Set the 'relevant user' in the skin, so it displays links like Contributions, + // User logs, UserRights, etc. + $this->getSkin()->setRelevantUser( $this->mFetchedUser ); + } + } + + // show a successbox, if the user rights was saved successfully + if ( + $session->get( 'specialUserrightsSaveSuccess' ) && + $this->mFetchedUser !== null + ) { + // Remove session data for the success message + $session->remove( 'specialUserrightsSaveSuccess' ); + + $out->addModuleStyles( 'mediawiki.notification.convertmessagebox.styles' ); + $out->addHTML( + Html::rawElement( + 'div', + [ + 'class' => 'mw-notify-success successbox', + 'id' => 'mw-preferences-success', + 'data-mw-autohide' => 'false', + ], + Html::element( + 'p', + [], + $this->msg( 'savedrights', $this->mFetchedUser->getName() )->text() + ) + ) + ); + } + + $this->setHeaders(); + $this->outputHeader(); + + $out->addModuleStyles( 'mediawiki.special' ); + $this->addHelpLink( 'Help:Assigning permissions' ); + + $this->switchForm(); + + if ( + $request->wasPosted() && + $request->getCheck( 'saveusergroups' ) && + $this->mTarget !== null && + $user->matchEditToken( $request->getVal( 'wpEditToken' ), $this->mTarget ) + ) { + /* + * If the user is blocked and they only have "partial" access + * (e.g. they don't have the userrights permission), then don't + * allow them to change any user rights. + */ + if ( $user->isBlocked() && !$user->isAllowed( 'userrights' ) ) { + throw new UserBlockedError( $user->getBlock() ); + } + + $this->checkReadOnly(); + + // save settings + if ( !$fetchedStatus->isOK() ) { + $this->getOutput()->addWikiText( $fetchedStatus->getWikiText() ); + + return; + } + + $targetUser = $this->mFetchedUser; + if ( $targetUser instanceof User ) { // UserRightsProxy doesn't have this method (T63252) + $targetUser->clearInstanceCache(); // T40989 + } + + if ( $request->getVal( 'conflictcheck-originalgroups' ) + !== implode( ',', $targetUser->getGroups() ) + ) { + $out->addWikiMsg( 'userrights-conflict' ); + } else { + $status = $this->saveUserGroups( + $this->mTarget, + $request->getVal( 'user-reason' ), + $targetUser + ); + + if ( $status->isOK() ) { + // Set session data for the success message + $session->set( 'specialUserrightsSaveSuccess', 1 ); + + $out->redirect( $this->getSuccessURL() ); + return; + } else { + // Print an error message and redisplay the form + $out->addWikiText( '<div class="error">' . $status->getWikiText() . '</div>' ); + } + } + } + + // show some more forms + if ( $this->mTarget !== null ) { + $this->editUserGroupsForm( $this->mTarget ); + } + } + + function getSuccessURL() { + return $this->getPageTitle( $this->mTarget )->getFullURL(); + } + + /** + * Returns true if this user rights form can set and change user group expiries. + * Subclasses may wish to override this to return false. + * + * @return bool + */ + public function canProcessExpiries() { + return true; + } + + /** + * Converts a user group membership expiry string into a timestamp. Words like + * 'existing' or 'other' should have been filtered out before calling this + * function. + * + * @param string $expiry + * @return string|null|false A string containing a valid timestamp, or null + * if the expiry is infinite, or false if the timestamp is not valid + */ + public static function expiryToTimestamp( $expiry ) { + if ( wfIsInfinity( $expiry ) ) { + return null; + } + + $unix = strtotime( $expiry ); + + if ( !$unix || $unix === -1 ) { + return false; + } + + // @todo FIXME: Non-qualified absolute times are not in users specified timezone + // and there isn't notice about it in the ui (see ProtectionForm::getExpiry) + return wfTimestamp( TS_MW, $unix ); + } + + /** + * Save user groups changes in the database. + * Data comes from the editUserGroupsForm() form function + * + * @param string $username Username to apply changes to. + * @param string $reason Reason for group change + * @param User|UserRightsProxy $user Target user object. + * @return Status + */ + protected function saveUserGroups( $username, $reason, $user ) { + $allgroups = $this->getAllGroups(); + $addgroup = []; + $groupExpiries = []; // associative array of (group name => expiry) + $removegroup = []; + $existingUGMs = $user->getGroupMemberships(); + + // This could possibly create a highly unlikely race condition if permissions are changed between + // when the form is loaded and when the form is saved. Ignoring it for the moment. + foreach ( $allgroups as $group ) { + // We'll tell it to remove all unchecked groups, and add all checked groups. + // Later on, this gets filtered for what can actually be removed + if ( $this->getRequest()->getCheck( "wpGroup-$group" ) ) { + $addgroup[] = $group; + + if ( $this->canProcessExpiries() ) { + // read the expiry information from the request + $expiryDropdown = $this->getRequest()->getVal( "wpExpiry-$group" ); + if ( $expiryDropdown === 'existing' ) { + continue; + } + + if ( $expiryDropdown === 'other' ) { + $expiryValue = $this->getRequest()->getVal( "wpExpiry-$group-other" ); + } else { + $expiryValue = $expiryDropdown; + } + + // validate the expiry + $groupExpiries[$group] = self::expiryToTimestamp( $expiryValue ); + + if ( $groupExpiries[$group] === false ) { + return Status::newFatal( 'userrights-invalid-expiry', $group ); + } + + // not allowed to have things expiring in the past + if ( $groupExpiries[$group] && $groupExpiries[$group] < wfTimestampNow() ) { + return Status::newFatal( 'userrights-expiry-in-past', $group ); + } + + // if the user can only add this group (not remove it), the expiry time + // cannot be brought forward (T156784) + if ( !$this->canRemove( $group ) && + isset( $existingUGMs[$group] ) && + ( $existingUGMs[$group]->getExpiry() ?: 'infinity' ) > + ( $groupExpiries[$group] ?: 'infinity' ) + ) { + return Status::newFatal( 'userrights-cannot-shorten-expiry', $group ); + } + } + } else { + $removegroup[] = $group; + } + } + + $this->doSaveUserGroups( $user, $addgroup, $removegroup, $reason, [], $groupExpiries ); + + return Status::newGood(); + } + + /** + * Save user groups changes in the database. This function does not throw errors; + * instead, it ignores groups that the performer does not have permission to set. + * + * @param User|UserRightsProxy $user + * @param array $add Array of groups to add + * @param array $remove Array of groups to remove + * @param string $reason Reason for group change + * @param array $tags Array of change tags to add to the log entry + * @param array $groupExpiries Associative array of (group name => expiry), + * containing only those groups that are to have new expiry values set + * @return array Tuple of added, then removed groups + */ + function doSaveUserGroups( $user, $add, $remove, $reason = '', $tags = [], + $groupExpiries = [] + ) { + // Validate input set... + $isself = $user->getName() == $this->getUser()->getName(); + $groups = $user->getGroups(); + $ugms = $user->getGroupMemberships(); + $changeable = $this->changeableGroups(); + $addable = array_merge( $changeable['add'], $isself ? $changeable['add-self'] : [] ); + $removable = array_merge( $changeable['remove'], $isself ? $changeable['remove-self'] : [] ); + + $remove = array_unique( + array_intersect( (array)$remove, $removable, $groups ) ); + $add = array_intersect( (array)$add, $addable ); + + // add only groups that are not already present or that need their expiry updated, + // UNLESS the user can only add this group (not remove it) and the expiry time + // is being brought forward (T156784) + $add = array_filter( $add, + function ( $group ) use ( $groups, $groupExpiries, $removable, $ugms ) { + if ( isset( $groupExpiries[$group] ) && + !in_array( $group, $removable ) && + isset( $ugms[$group] ) && + ( $ugms[$group]->getExpiry() ?: 'infinity' ) > + ( $groupExpiries[$group] ?: 'infinity' ) + ) { + return false; + } + return !in_array( $group, $groups ) || array_key_exists( $group, $groupExpiries ); + } ); + + Hooks::run( 'ChangeUserGroups', [ $this->getUser(), $user, &$add, &$remove ] ); + + $oldGroups = $groups; + $oldUGMs = $user->getGroupMemberships(); + $newGroups = $oldGroups; + + // Remove groups, then add new ones/update expiries of existing ones + if ( $remove ) { + foreach ( $remove as $index => $group ) { + if ( !$user->removeGroup( $group ) ) { + unset( $remove[$index] ); + } + } + $newGroups = array_diff( $newGroups, $remove ); + } + if ( $add ) { + foreach ( $add as $index => $group ) { + $expiry = isset( $groupExpiries[$group] ) ? $groupExpiries[$group] : null; + if ( !$user->addGroup( $group, $expiry ) ) { + unset( $add[$index] ); + } + } + $newGroups = array_merge( $newGroups, $add ); + } + $newGroups = array_unique( $newGroups ); + $newUGMs = $user->getGroupMemberships(); + + // Ensure that caches are cleared + $user->invalidateCache(); + + // update groups in external authentication database + Hooks::run( 'UserGroupsChanged', [ $user, $add, $remove, $this->getUser(), + $reason, $oldUGMs, $newUGMs ] ); + MediaWiki\Auth\AuthManager::callLegacyAuthPlugin( + 'updateExternalDBGroups', [ $user, $add, $remove ] + ); + + wfDebug( 'oldGroups: ' . print_r( $oldGroups, true ) . "\n" ); + wfDebug( 'newGroups: ' . print_r( $newGroups, true ) . "\n" ); + wfDebug( 'oldUGMs: ' . print_r( $oldUGMs, true ) . "\n" ); + wfDebug( 'newUGMs: ' . print_r( $newUGMs, true ) . "\n" ); + // Deprecated in favor of UserGroupsChanged hook + Hooks::run( 'UserRights', [ &$user, $add, $remove ], '1.26' ); + + // Only add a log entry if something actually changed + if ( $newGroups != $oldGroups || $newUGMs != $oldUGMs ) { + $this->addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags, $oldUGMs, $newUGMs ); + } + + return [ $add, $remove ]; + } + + /** + * Serialise a UserGroupMembership object for storage in the log_params section + * of the logging table. Only keeps essential data, removing redundant fields. + * + * @param UserGroupMembership|null $ugm May be null if things get borked + * @return array + */ + protected static function serialiseUgmForLog( $ugm ) { + if ( !$ugm instanceof UserGroupMembership ) { + return null; + } + return [ 'expiry' => $ugm->getExpiry() ]; + } + + /** + * Add a rights log entry for an action. + * @param User|UserRightsProxy $user + * @param array $oldGroups + * @param array $newGroups + * @param array $reason + * @param array $tags Change tags for the log entry + * @param array $oldUGMs Associative array of (group name => UserGroupMembership) + * @param array $newUGMs Associative array of (group name => UserGroupMembership) + */ + protected function addLogEntry( $user, $oldGroups, $newGroups, $reason, $tags, + $oldUGMs, $newUGMs + ) { + // make sure $oldUGMs and $newUGMs are in the same order, and serialise + // each UGM object to a simplified array + $oldUGMs = array_map( function ( $group ) use ( $oldUGMs ) { + return isset( $oldUGMs[$group] ) ? + self::serialiseUgmForLog( $oldUGMs[$group] ) : + null; + }, $oldGroups ); + $newUGMs = array_map( function ( $group ) use ( $newUGMs ) { + return isset( $newUGMs[$group] ) ? + self::serialiseUgmForLog( $newUGMs[$group] ) : + null; + }, $newGroups ); + + $logEntry = new ManualLogEntry( 'rights', 'rights' ); + $logEntry->setPerformer( $this->getUser() ); + $logEntry->setTarget( $user->getUserPage() ); + $logEntry->setComment( $reason ); + $logEntry->setParameters( [ + '4::oldgroups' => $oldGroups, + '5::newgroups' => $newGroups, + 'oldmetadata' => $oldUGMs, + 'newmetadata' => $newUGMs, + ] ); + $logid = $logEntry->insert(); + if ( count( $tags ) ) { + $logEntry->setTags( $tags ); + } + $logEntry->publish( $logid ); + } + + /** + * Edit user groups membership + * @param string $username Name of the user. + */ + function editUserGroupsForm( $username ) { + $status = $this->fetchUser( $username, true ); + if ( !$status->isOK() ) { + $this->getOutput()->addWikiText( $status->getWikiText() ); + + return; + } else { + $user = $status->value; + } + + $groups = $user->getGroups(); + $groupMemberships = $user->getGroupMemberships(); + $this->showEditUserGroupsForm( $user, $groups, $groupMemberships ); + + // This isn't really ideal logging behavior, but let's not hide the + // interwiki logs if we're using them as is. + $this->showLogFragment( $user, $this->getOutput() ); + } + + /** + * Normalize the input username, which may be local or remote, and + * return a user (or proxy) object for manipulating it. + * + * Side effects: error output for invalid access + * @param string $username + * @param bool $writing + * @return Status + */ + public function fetchUser( $username, $writing = true ) { + $parts = explode( $this->getConfig()->get( 'UserrightsInterwikiDelimiter' ), $username ); + if ( count( $parts ) < 2 ) { + $name = trim( $username ); + $database = ''; + } else { + list( $name, $database ) = array_map( 'trim', $parts ); + + if ( $database == wfWikiID() ) { + $database = ''; + } else { + if ( $writing && !$this->getUser()->isAllowed( 'userrights-interwiki' ) ) { + return Status::newFatal( 'userrights-no-interwiki' ); + } + if ( !UserRightsProxy::validDatabase( $database ) ) { + return Status::newFatal( 'userrights-nodatabase', $database ); + } + } + } + + if ( $name === '' ) { + return Status::newFatal( 'nouserspecified' ); + } + + if ( $name[0] == '#' ) { + // Numeric ID can be specified... + // We'll do a lookup for the name internally. + $id = intval( substr( $name, 1 ) ); + + if ( $database == '' ) { + $name = User::whoIs( $id ); + } else { + $name = UserRightsProxy::whoIs( $database, $id ); + } + + if ( !$name ) { + return Status::newFatal( 'noname' ); + } + } else { + $name = User::getCanonicalName( $name ); + if ( $name === false ) { + // invalid name + return Status::newFatal( 'nosuchusershort', $username ); + } + } + + if ( $database == '' ) { + $user = User::newFromName( $name ); + } else { + $user = UserRightsProxy::newFromName( $database, $name ); + } + + if ( !$user || $user->isAnon() ) { + return Status::newFatal( 'nosuchusershort', $username ); + } + + return Status::newGood( $user ); + } + + /** + * @since 1.15 + * + * @param array $ids + * + * @return string + */ + public function makeGroupNameList( $ids ) { + if ( empty( $ids ) ) { + return $this->msg( 'rightsnone' )->inContentLanguage()->text(); + } else { + return implode( ', ', $ids ); + } + } + + /** + * Output a form to allow searching for a user + */ + function switchForm() { + $this->getOutput()->addModules( 'mediawiki.userSuggest' ); + + $this->getOutput()->addHTML( + Html::openElement( + 'form', + [ + 'method' => 'get', + 'action' => wfScript(), + 'name' => 'uluser', + 'id' => 'mw-userrights-form1' + ] + ) . + Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . + Xml::fieldset( $this->msg( 'userrights-lookup-user' )->text() ) . + Xml::inputLabel( + $this->msg( 'userrights-user-editname' )->text(), + 'user', + 'username', + 30, + str_replace( '_', ' ', $this->mTarget ), + [ + 'class' => 'mw-autocomplete-user', // used by mediawiki.userSuggest + ] + ( + // Set autofocus on blank input and error input + $this->mFetchedUser === null ? [ 'autofocus' => '' ] : [] + ) + ) . ' ' . + Xml::submitButton( + $this->msg( 'editusergroup' )->text() + ) . + Html::closeElement( 'fieldset' ) . + Html::closeElement( 'form' ) . "\n" + ); + } + + /** + * Show the form to edit group memberships. + * + * @param User|UserRightsProxy $user User or UserRightsProxy you're editing + * @param array $groups Array of groups the user is in. Not used by this implementation + * anymore, but kept for backward compatibility with subclasses + * @param array $groupMemberships Associative array of (group name => UserGroupMembership + * object) containing the groups the user is in + */ + protected function showEditUserGroupsForm( $user, $groups, $groupMemberships ) { + $list = $membersList = $tempList = $tempMembersList = []; + foreach ( $groupMemberships as $ugm ) { + $linkG = UserGroupMembership::getLink( $ugm, $this->getContext(), 'html' ); + $linkM = UserGroupMembership::getLink( $ugm, $this->getContext(), 'html', + $user->getName() ); + if ( $ugm->getExpiry() ) { + $tempList[] = $linkG; + $tempMembersList[] = $linkM; + } else { + $list[] = $linkG; + $membersList[] = $linkM; + + } + } + + $autoList = []; + $autoMembersList = []; + if ( $user instanceof User ) { + foreach ( Autopromote::getAutopromoteGroups( $user ) as $group ) { + $autoList[] = UserGroupMembership::getLink( $group, $this->getContext(), 'html' ); + $autoMembersList[] = UserGroupMembership::getLink( $group, $this->getContext(), + 'html', $user->getName() ); + } + } + + $language = $this->getLanguage(); + $displayedList = $this->msg( 'userrights-groupsmember-type' ) + ->rawParams( + $language->commaList( array_merge( $tempList, $list ) ), + $language->commaList( array_merge( $tempMembersList, $membersList ) ) + )->escaped(); + $displayedAutolist = $this->msg( 'userrights-groupsmember-type' ) + ->rawParams( + $language->commaList( $autoList ), + $language->commaList( $autoMembersList ) + )->escaped(); + + $grouplist = ''; + $count = count( $list ); + if ( $count > 0 ) { + $grouplist = $this->msg( 'userrights-groupsmember' ) + ->numParams( $count ) + ->params( $user->getName() ) + ->parse(); + $grouplist = '<p>' . $grouplist . ' ' . $displayedList . "</p>\n"; + } + + $count = count( $autoList ); + if ( $count > 0 ) { + $autogrouplistintro = $this->msg( 'userrights-groupsmember-auto' ) + ->numParams( $count ) + ->params( $user->getName() ) + ->parse(); + $grouplist .= '<p>' . $autogrouplistintro . ' ' . $displayedAutolist . "</p>\n"; + } + + $userToolLinks = Linker::userToolLinks( + $user->getId(), + $user->getName(), + false, /* default for redContribsWhenNoEdits */ + Linker::TOOL_LINKS_EMAIL /* Add "send e-mail" link */ + ); + + list( $groupCheckboxes, $canChangeAny ) = + $this->groupCheckboxes( $groupMemberships, $user ); + $this->getOutput()->addHTML( + Xml::openElement( + 'form', + [ + 'method' => 'post', + 'action' => $this->getPageTitle()->getLocalURL(), + 'name' => 'editGroup', + 'id' => 'mw-userrights-form2' + ] + ) . + Html::hidden( 'user', $this->mTarget ) . + Html::hidden( 'wpEditToken', $this->getUser()->getEditToken( $this->mTarget ) ) . + Html::hidden( + 'conflictcheck-originalgroups', + implode( ',', $user->getGroups() ) + ) . // Conflict detection + Xml::openElement( 'fieldset' ) . + Xml::element( + 'legend', + [], + $this->msg( + $canChangeAny ? 'userrights-editusergroup' : 'userrights-viewusergroup', + $user->getName() + )->text() + ) . + $this->msg( + $canChangeAny ? 'editinguser' : 'viewinguserrights' + )->params( wfEscapeWikiText( $user->getName() ) ) + ->rawParams( $userToolLinks )->parse() + ); + if ( $canChangeAny ) { + $conf = $this->getConfig(); + $oldCommentSchema = $conf->get( 'CommentTableSchemaMigrationStage' ) === MIGRATION_OLD; + $this->getOutput()->addHTML( + $this->msg( 'userrights-groups-help', $user->getName() )->parse() . + $grouplist . + $groupCheckboxes . + Xml::openElement( 'table', [ 'id' => 'mw-userrights-table-outer' ] ) . + "<tr> + <td class='mw-label'>" . + Xml::label( $this->msg( 'userrights-reason' )->text(), 'wpReason' ) . + "</td> + <td class='mw-input'>" . + Xml::input( 'user-reason', 60, $this->getRequest()->getVal( 'user-reason', false ), [ + '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 (or 255 UTF-8 bytes for old schema). + 'maxlength' => $oldCommentSchema ? 255 : CommentStore::COMMENT_CHARACTER_LIMIT, + ] ) . + "</td> + </tr> + <tr> + <td></td> + <td class='mw-submit'>" . + Xml::submitButton( $this->msg( 'saveusergroups', $user->getName() )->text(), + [ 'name' => 'saveusergroups' ] + + Linker::tooltipAndAccesskeyAttribs( 'userrights-set' ) + ) . + "</td> + </tr>" . + Xml::closeElement( 'table' ) . "\n" + ); + } else { + $this->getOutput()->addHTML( $grouplist ); + } + $this->getOutput()->addHTML( + Xml::closeElement( 'fieldset' ) . + Xml::closeElement( 'form' ) . "\n" + ); + } + + /** + * Returns an array of all groups that may be edited + * @return array Array of groups that may be edited. + */ + protected static function getAllGroups() { + return User::getAllGroups(); + } + + /** + * Adds a table with checkboxes where you can select what groups to add/remove + * + * @param UserGroupMembership[] $usergroups Associative array of (group name as string => + * UserGroupMembership object) for groups the user belongs to + * @param User $user + * @return Array with 2 elements: the XHTML table element with checkxboes, and + * whether any groups are changeable + */ + private function groupCheckboxes( $usergroups, $user ) { + $allgroups = $this->getAllGroups(); + $ret = ''; + + // Get the list of preset expiry times from the system message + $expiryOptionsMsg = $this->msg( 'userrights-expiry-options' )->inContentLanguage(); + $expiryOptions = $expiryOptionsMsg->isDisabled() ? + [] : + explode( ',', $expiryOptionsMsg->text() ); + + // Put all column info into an associative array so that extensions can + // more easily manage it. + $columns = [ 'unchangeable' => [], 'changeable' => [] ]; + + foreach ( $allgroups as $group ) { + $set = isset( $usergroups[$group] ); + // Users who can add the group, but not remove it, can only lengthen + // expiries, not shorten them. So they should only see the expiry + // dropdown if the group currently has a finite expiry + $canOnlyLengthenExpiry = ( $set && $this->canAdd( $group ) && + !$this->canRemove( $group ) && $usergroups[$group]->getExpiry() ); + // Should the checkbox be disabled? + $disabledCheckbox = !( + ( $set && $this->canRemove( $group ) ) || + ( !$set && $this->canAdd( $group ) ) ); + // Should the expiry elements be disabled? + $disabledExpiry = $disabledCheckbox && !$canOnlyLengthenExpiry; + // Do we need to point out that this action is irreversible? + $irreversible = !$disabledCheckbox && ( + ( $set && !$this->canAdd( $group ) ) || + ( !$set && !$this->canRemove( $group ) ) ); + + $checkbox = [ + 'set' => $set, + 'disabled' => $disabledCheckbox, + 'disabled-expiry' => $disabledExpiry, + 'irreversible' => $irreversible + ]; + + if ( $disabledCheckbox && $disabledExpiry ) { + $columns['unchangeable'][$group] = $checkbox; + } else { + $columns['changeable'][$group] = $checkbox; + } + } + + // Build the HTML table + $ret .= Xml::openElement( 'table', [ 'class' => 'mw-userrights-groups' ] ) . + "<tr>\n"; + foreach ( $columns as $name => $column ) { + if ( $column === [] ) { + continue; + } + // Messages: userrights-changeable-col, userrights-unchangeable-col + $ret .= Xml::element( + 'th', + null, + $this->msg( 'userrights-' . $name . '-col', count( $column ) )->text() + ); + } + + $ret .= "</tr>\n<tr>\n"; + foreach ( $columns as $column ) { + if ( $column === [] ) { + continue; + } + $ret .= "\t<td style='vertical-align:top;'>\n"; + foreach ( $column as $group => $checkbox ) { + $attr = [ 'class' => 'mw-userrights-groupcheckbox' ]; + if ( $checkbox['disabled'] ) { + $attr['disabled'] = 'disabled'; + } + + $member = UserGroupMembership::getGroupMemberName( $group, $user->getName() ); + if ( $checkbox['irreversible'] ) { + $text = $this->msg( 'userrights-irreversible-marker', $member )->text(); + } elseif ( $checkbox['disabled'] && !$checkbox['disabled-expiry'] ) { + $text = $this->msg( 'userrights-no-shorten-expiry-marker', $member )->text(); + } else { + $text = $member; + } + $checkboxHtml = Xml::checkLabel( $text, "wpGroup-" . $group, + "wpGroup-" . $group, $checkbox['set'], $attr ); + + if ( $this->canProcessExpiries() ) { + $uiUser = $this->getUser(); + $uiLanguage = $this->getLanguage(); + + $currentExpiry = isset( $usergroups[$group] ) ? + $usergroups[$group]->getExpiry() : + null; + + // If the user can't modify the expiry, print the current expiry below + // it in plain text. Otherwise provide UI to set/change the expiry + if ( $checkbox['set'] && + ( $checkbox['irreversible'] || $checkbox['disabled-expiry'] ) + ) { + if ( $currentExpiry ) { + $expiryFormatted = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser ); + $expiryFormattedD = $uiLanguage->userDate( $currentExpiry, $uiUser ); + $expiryFormattedT = $uiLanguage->userTime( $currentExpiry, $uiUser ); + $expiryHtml = $this->msg( 'userrights-expiry-current' )->params( + $expiryFormatted, $expiryFormattedD, $expiryFormattedT )->text(); + } else { + $expiryHtml = $this->msg( 'userrights-expiry-none' )->text(); + } + // T171345: Add a hidden form element so that other groups can still be manipulated, + // otherwise saving errors out with an invalid expiry time for this group. + $expiryHtml .= Html::Hidden( "wpExpiry-$group", + $currentExpiry ? 'existing' : 'infinite' ); + $expiryHtml .= "<br />\n"; + } else { + $expiryHtml = Xml::element( 'span', null, + $this->msg( 'userrights-expiry' )->text() ); + $expiryHtml .= Xml::openElement( 'span' ); + + // add a form element to set the expiry date + $expiryFormOptions = new XmlSelect( + "wpExpiry-$group", + "mw-input-wpExpiry-$group", // forward compatibility with HTMLForm + $currentExpiry ? 'existing' : 'infinite' + ); + if ( $checkbox['disabled-expiry'] ) { + $expiryFormOptions->setAttribute( 'disabled', 'disabled' ); + } + + if ( $currentExpiry ) { + $timestamp = $uiLanguage->userTimeAndDate( $currentExpiry, $uiUser ); + $d = $uiLanguage->userDate( $currentExpiry, $uiUser ); + $t = $uiLanguage->userTime( $currentExpiry, $uiUser ); + $existingExpiryMessage = $this->msg( 'userrights-expiry-existing', + $timestamp, $d, $t ); + $expiryFormOptions->addOption( $existingExpiryMessage->text(), 'existing' ); + } + + $expiryFormOptions->addOption( + $this->msg( 'userrights-expiry-none' )->text(), + 'infinite' + ); + $expiryFormOptions->addOption( + $this->msg( 'userrights-expiry-othertime' )->text(), + 'other' + ); + foreach ( $expiryOptions as $option ) { + if ( strpos( $option, ":" ) === false ) { + $displayText = $value = $option; + } else { + list( $displayText, $value ) = explode( ":", $option ); + } + $expiryFormOptions->addOption( $displayText, htmlspecialchars( $value ) ); + } + + // Add expiry dropdown + $expiryHtml .= $expiryFormOptions->getHTML() . '<br />'; + + // Add custom expiry field + $attribs = [ + 'id' => "mw-input-wpExpiry-$group-other", + 'class' => 'mw-userrights-expiryfield', + ]; + if ( $checkbox['disabled-expiry'] ) { + $attribs['disabled'] = 'disabled'; + } + $expiryHtml .= Xml::input( "wpExpiry-$group-other", 30, '', $attribs ); + + // If the user group is set but the checkbox is disabled, mimic a + // checked checkbox in the form submission + if ( $checkbox['set'] && $checkbox['disabled'] ) { + $expiryHtml .= Html::hidden( "wpGroup-$group", 1 ); + } + + $expiryHtml .= Xml::closeElement( 'span' ); + } + + $divAttribs = [ + 'id' => "mw-userrights-nested-wpGroup-$group", + 'class' => 'mw-userrights-nested', + ]; + $checkboxHtml .= "\t\t\t" . Xml::tags( 'div', $divAttribs, $expiryHtml ) . "\n"; + } + $ret .= "\t\t" . ( ( $checkbox['disabled'] && $checkbox['disabled-expiry'] ) + ? Xml::tags( 'div', [ 'class' => 'mw-userrights-disabled' ], $checkboxHtml ) + : Xml::tags( 'div', [], $checkboxHtml ) + ) . "\n"; + } + $ret .= "\t</td>\n"; + } + $ret .= Xml::closeElement( 'tr' ) . Xml::closeElement( 'table' ); + + return [ $ret, (bool)$columns['changeable'] ]; + } + + /** + * @param string $group The name of the group to check + * @return bool Can we remove the group? + */ + private function canRemove( $group ) { + $groups = $this->changeableGroups(); + + return in_array( + $group, + $groups['remove'] ) || ( $this->isself && in_array( $group, $groups['remove-self'] ) + ); + } + + /** + * @param string $group The name of the group to check + * @return bool Can we add the group? + */ + private function canAdd( $group ) { + $groups = $this->changeableGroups(); + + return in_array( + $group, + $groups['add'] ) || ( $this->isself && in_array( $group, $groups['add-self'] ) + ); + } + + /** + * Returns $this->getUser()->changeableGroups() + * + * @return array Array( + * 'add' => array( addablegroups ), + * 'remove' => array( removablegroups ), + * 'add-self' => array( addablegroups to self ), + * 'remove-self' => array( removable groups from self ) + * ) + */ + function changeableGroups() { + return $this->getUser()->changeableGroups(); + } + + /** + * Show a rights log fragment for the specified user + * + * @param User $user User to show log for + * @param OutputPage $output OutputPage to use + */ + protected function showLogFragment( $user, $output ) { + $rightsLogPage = new LogPage( 'rights' ); + $output->addHTML( Xml::element( 'h2', null, $rightsLogPage->getName()->text() ) ); + LogEventsList::showLogExtract( $output, 'rights', $user->getUserPage() ); + } + + /** + * 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'; + } +} diff --git a/www/wiki/includes/specials/SpecialVersion.php b/www/wiki/includes/specials/SpecialVersion.php new file mode 100644 index 00000000..6590756f --- /dev/null +++ b/www/wiki/includes/specials/SpecialVersion.php @@ -0,0 +1,1201 @@ +<?php +/** + * Implements Special:Version + * + * Copyright © 2005 Ævar Arnfjörð Bjarmason + * + * 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 + */ + +/** + * Give information about the version of MediaWiki, PHP, the DB and extensions + * + * @ingroup SpecialPage + */ +class SpecialVersion extends SpecialPage { + protected $firstExtOpened = false; + + /** + * Stores the current rev id/SHA hash of MediaWiki core + */ + protected $coreId = ''; + + protected static $extensionTypes = false; + + public function __construct() { + parent::__construct( 'Version' ); + } + + /** + * main() + * @param string|null $par + */ + public function execute( $par ) { + global $IP, $wgExtensionCredits; + + $this->setHeaders(); + $this->outputHeader(); + $out = $this->getOutput(); + $out->allowClickjacking(); + + // Explode the sub page information into useful bits + $parts = explode( '/', (string)$par ); + $extNode = null; + if ( isset( $parts[1] ) ) { + $extName = str_replace( '_', ' ', $parts[1] ); + // Find it! + foreach ( $wgExtensionCredits as $group => $extensions ) { + foreach ( $extensions as $ext ) { + if ( isset( $ext['name'] ) && ( $ext['name'] === $extName ) ) { + $extNode = &$ext; + break 2; + } + } + } + if ( !$extNode ) { + $out->setStatusCode( 404 ); + } + } else { + $extName = 'MediaWiki'; + } + + // Now figure out what to do + switch ( strtolower( $parts[0] ) ) { + case 'credits': + $out->addModuleStyles( 'mediawiki.special.version' ); + + $wikiText = '{{int:version-credits-not-found}}'; + if ( $extName === 'MediaWiki' ) { + $wikiText = file_get_contents( $IP . '/CREDITS' ); + // Put the contributor list into columns + $wikiText = str_replace( + [ '<!-- BEGIN CONTRIBUTOR LIST -->', '<!-- END CONTRIBUTOR LIST -->' ], + [ '<div class="mw-version-credits">', '</div>' ], + $wikiText ); + } elseif ( ( $extNode !== null ) && isset( $extNode['path'] ) ) { + $file = $this->getExtAuthorsFileName( dirname( $extNode['path'] ) ); + if ( $file ) { + $wikiText = file_get_contents( $file ); + if ( substr( $file, -4 ) === '.txt' ) { + $wikiText = Html::element( + 'pre', + [ + 'lang' => 'en', + 'dir' => 'ltr', + ], + $wikiText + ); + } + } + } + + $out->setPageTitle( $this->msg( 'version-credits-title', $extName ) ); + $out->addWikiText( $wikiText ); + break; + + case 'license': + $wikiText = '{{int:version-license-not-found}}'; + if ( $extName === 'MediaWiki' ) { + $wikiText = file_get_contents( $IP . '/COPYING' ); + } elseif ( ( $extNode !== null ) && isset( $extNode['path'] ) ) { + $file = $this->getExtLicenseFileName( dirname( $extNode['path'] ) ); + if ( $file ) { + $wikiText = file_get_contents( $file ); + $wikiText = Html::element( + 'pre', + [ + 'lang' => 'en', + 'dir' => 'ltr', + ], + $wikiText + ); + } + } + + $out->setPageTitle( $this->msg( 'version-license-title', $extName ) ); + $out->addWikiText( $wikiText ); + break; + + default: + $out->addModuleStyles( 'mediawiki.special.version' ); + $out->addWikiText( + $this->getMediaWikiCredits() . + $this->softwareInformation() . + $this->getEntryPointInfo() + ); + $out->addHTML( + $this->getSkinCredits() . + $this->getExtensionCredits() . + $this->getExternalLibraries() . + $this->getParserTags() . + $this->getParserFunctionHooks() + ); + $out->addWikiText( $this->getWgHooks() ); + $out->addHTML( $this->IPInfo() ); + + break; + } + } + + /** + * Returns wiki text showing the license information. + * + * @return string + */ + private static function getMediaWikiCredits() { + $ret = Xml::element( + 'h2', + [ 'id' => 'mw-version-license' ], + wfMessage( 'version-license' )->text() + ); + + // This text is always left-to-right. + $ret .= '<div class="plainlinks">'; + $ret .= "__NOTOC__ + " . self::getCopyrightAndAuthorList() . "\n + " . '<div class="mw-version-license-info">' . + wfMessage( 'version-license-info' )->text() . + '</div>'; + $ret .= '</div>'; + + return str_replace( "\t\t", '', $ret ) . "\n"; + } + + /** + * Get the "MediaWiki is copyright 2001-20xx by lots of cool guys" text + * + * @return string + */ + public static function getCopyrightAndAuthorList() { + global $wgLang; + + if ( defined( 'MEDIAWIKI_INSTALL' ) ) { + $othersLink = '[https://www.mediawiki.org/wiki/Special:Version/Credits ' . + wfMessage( 'version-poweredby-others' )->text() . ']'; + } else { + $othersLink = '[[Special:Version/Credits|' . + wfMessage( 'version-poweredby-others' )->text() . ']]'; + } + + $translatorsLink = '[https://translatewiki.net/wiki/Translating:MediaWiki/Credits ' . + wfMessage( 'version-poweredby-translators' )->text() . ']'; + + $authorList = [ + 'Magnus Manske', 'Brion Vibber', 'Lee Daniel Crocker', + 'Tim Starling', 'Erik Möller', 'Gabriel Wicke', 'Ævar Arnfjörð Bjarmason', + 'Niklas Laxström', 'Domas Mituzas', 'Rob Church', 'Yuri Astrakhan', + 'Aryeh Gregor', 'Aaron Schulz', 'Andrew Garrett', 'Raimond Spekking', + 'Alexandre Emsenhuber', 'Siebrand Mazeland', 'Chad Horohoe', + 'Roan Kattouw', 'Trevor Parscal', 'Bryan Tong Minh', 'Sam Reed', + 'Victor Vasiliev', 'Rotem Liss', 'Platonides', 'Antoine Musso', + 'Timo Tijhof', 'Daniel Kinzler', 'Jeroen De Dauw', 'Brad Jorsch', + 'Bartosz Dziewoński', 'Ed Sanders', 'Moriel Schottlender', + $othersLink, $translatorsLink + ]; + + return wfMessage( 'version-poweredby-credits', MWTimestamp::getLocalInstance()->format( 'Y' ), + $wgLang->listToText( $authorList ) )->text(); + } + + /** + * Returns wiki text showing the third party software versions (apache, php, mysql). + * + * @return string + */ + public static function softwareInformation() { + $dbr = wfGetDB( DB_REPLICA ); + + // Put the software in an array of form 'name' => 'version'. All messages should + // be loaded here, so feel free to use wfMessage in the 'name'. Raw HTML or + // wikimarkup can be used. + $software = []; + $software['[https://www.mediawiki.org/ MediaWiki]'] = self::getVersionLinked(); + if ( wfIsHHVM() ) { + $software['[http://hhvm.com/ HHVM]'] = HHVM_VERSION . " (" . PHP_SAPI . ")"; + } else { + $software['[https://php.net/ PHP]'] = PHP_VERSION . " (" . PHP_SAPI . ")"; + } + $software[$dbr->getSoftwareLink()] = $dbr->getServerInfo(); + + if ( IcuCollation::getICUVersion() ) { + $software['[http://site.icu-project.org/ ICU]'] = IcuCollation::getICUVersion(); + } + + // Allow a hook to add/remove items. + Hooks::run( 'SoftwareInfo', [ &$software ] ); + + $out = Xml::element( + 'h2', + [ 'id' => 'mw-version-software' ], + wfMessage( 'version-software' )->text() + ) . + Xml::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-software' ] ) . + "<tr> + <th>" . wfMessage( 'version-software-product' )->text() . "</th> + <th>" . wfMessage( 'version-software-version' )->text() . "</th> + </tr>\n"; + + foreach ( $software as $name => $version ) { + $out .= "<tr> + <td>" . $name . "</td> + <td dir=\"ltr\">" . $version . "</td> + </tr>\n"; + } + + return $out . Xml::closeElement( 'table' ); + } + + /** + * Return a string of the MediaWiki version with Git revision if available. + * + * @param string $flags + * @param Language|string|null $lang + * @return mixed + */ + public static function getVersion( $flags = '', $lang = null ) { + global $wgVersion, $IP; + + $gitInfo = self::getGitHeadSha1( $IP ); + if ( !$gitInfo ) { + $version = $wgVersion; + } elseif ( $flags === 'nodb' ) { + $shortSha1 = substr( $gitInfo, 0, 7 ); + $version = "$wgVersion ($shortSha1)"; + } else { + $shortSha1 = substr( $gitInfo, 0, 7 ); + $msg = wfMessage( 'parentheses' ); + if ( $lang !== null ) { + $msg->inLanguage( $lang ); + } + $shortSha1 = $msg->params( $shortSha1 )->escaped(); + $version = "$wgVersion $shortSha1"; + } + + return $version; + } + + /** + * Return a wikitext-formatted string of the MediaWiki version with a link to + * the Git SHA1 of head if available. + * The fallback is just $wgVersion + * + * @return mixed + */ + public static function getVersionLinked() { + global $wgVersion; + + $gitVersion = self::getVersionLinkedGit(); + if ( $gitVersion ) { + $v = $gitVersion; + } else { + $v = $wgVersion; // fallback + } + + return $v; + } + + /** + * @return string + */ + private static function getwgVersionLinked() { + global $wgVersion; + $versionUrl = ""; + if ( Hooks::run( 'SpecialVersionVersionUrl', [ $wgVersion, &$versionUrl ] ) ) { + $versionParts = []; + preg_match( "/^(\d+\.\d+)/", $wgVersion, $versionParts ); + $versionUrl = "https://www.mediawiki.org/wiki/MediaWiki_{$versionParts[1]}"; + } + + return "[$versionUrl $wgVersion]"; + } + + /** + * @since 1.22 Returns the HEAD date in addition to the sha1 and link + * @return bool|string Global wgVersion + HEAD sha1 stripped to the first 7 chars + * with link and date, or false on failure + */ + private static function getVersionLinkedGit() { + global $IP, $wgLang; + + $gitInfo = new GitInfo( $IP ); + $headSHA1 = $gitInfo->getHeadSHA1(); + if ( !$headSHA1 ) { + return false; + } + + $shortSHA1 = '(' . substr( $headSHA1, 0, 7 ) . ')'; + + $gitHeadUrl = $gitInfo->getHeadViewUrl(); + if ( $gitHeadUrl !== false ) { + $shortSHA1 = "[$gitHeadUrl $shortSHA1]"; + } + + $gitHeadCommitDate = $gitInfo->getHeadCommitDate(); + if ( $gitHeadCommitDate ) { + $shortSHA1 .= Html::element( 'br' ) . $wgLang->timeanddate( $gitHeadCommitDate, true ); + } + + return self::getwgVersionLinked() . " $shortSHA1"; + } + + /** + * Returns an array with the base extension types. + * Type is stored as array key, the message as array value. + * + * TODO: ideally this would return all extension types. + * + * @since 1.17 + * + * @return array + */ + public static function getExtensionTypes() { + if ( self::$extensionTypes === false ) { + self::$extensionTypes = [ + 'specialpage' => wfMessage( 'version-specialpages' )->text(), + 'editor' => wfMessage( 'version-editors' )->text(), + 'parserhook' => wfMessage( 'version-parserhooks' )->text(), + 'variable' => wfMessage( 'version-variables' )->text(), + 'media' => wfMessage( 'version-mediahandlers' )->text(), + 'antispam' => wfMessage( 'version-antispam' )->text(), + 'skin' => wfMessage( 'version-skins' )->text(), + 'api' => wfMessage( 'version-api' )->text(), + 'other' => wfMessage( 'version-other' )->text(), + ]; + + Hooks::run( 'ExtensionTypes', [ &self::$extensionTypes ] ); + } + + return self::$extensionTypes; + } + + /** + * Returns the internationalized name for an extension type. + * + * @since 1.17 + * + * @param string $type + * + * @return string + */ + public static function getExtensionTypeName( $type ) { + $types = self::getExtensionTypes(); + + return isset( $types[$type] ) ? $types[$type] : $types['other']; + } + + /** + * Generate wikitext showing the name, URL, author and description of each extension. + * + * @return string Wikitext + */ + public function getExtensionCredits() { + global $wgExtensionCredits; + + if ( + count( $wgExtensionCredits ) === 0 || + // Skins are displayed separately, see getSkinCredits() + ( count( $wgExtensionCredits ) === 1 && isset( $wgExtensionCredits['skin'] ) ) + ) { + return ''; + } + + $extensionTypes = self::getExtensionTypes(); + + $out = Xml::element( + 'h2', + [ 'id' => 'mw-version-ext' ], + $this->msg( 'version-extensions' )->text() + ) . + Xml::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-ext' ] ); + + // Make sure the 'other' type is set to an array. + if ( !array_key_exists( 'other', $wgExtensionCredits ) ) { + $wgExtensionCredits['other'] = []; + } + + // Find all extensions that do not have a valid type and give them the type 'other'. + foreach ( $wgExtensionCredits as $type => $extensions ) { + if ( !array_key_exists( $type, $extensionTypes ) ) { + $wgExtensionCredits['other'] = array_merge( $wgExtensionCredits['other'], $extensions ); + } + } + + $this->firstExtOpened = false; + // Loop through the extension categories to display their extensions in the list. + foreach ( $extensionTypes as $type => $message ) { + // Skins have a separate section + if ( $type !== 'other' && $type !== 'skin' ) { + $out .= $this->getExtensionCategory( $type, $message ); + } + } + + // We want the 'other' type to be last in the list. + $out .= $this->getExtensionCategory( 'other', $extensionTypes['other'] ); + + $out .= Xml::closeElement( 'table' ); + + return $out; + } + + /** + * Generate wikitext showing the name, URL, author and description of each skin. + * + * @return string Wikitext + */ + public function getSkinCredits() { + global $wgExtensionCredits; + if ( !isset( $wgExtensionCredits['skin'] ) || count( $wgExtensionCredits['skin'] ) === 0 ) { + return ''; + } + + $out = Xml::element( + 'h2', + [ 'id' => 'mw-version-skin' ], + $this->msg( 'version-skins' )->text() + ) . + Xml::openElement( 'table', [ 'class' => 'wikitable plainlinks', 'id' => 'sv-skin' ] ); + + $this->firstExtOpened = false; + $out .= $this->getExtensionCategory( 'skin', null ); + + $out .= Xml::closeElement( 'table' ); + + return $out; + } + + /** + * Generate an HTML table for external libraries that are installed + * + * @return string + */ + protected function getExternalLibraries() { + global $IP; + $path = "$IP/vendor/composer/installed.json"; + if ( !file_exists( $path ) ) { + return ''; + } + + $installed = new ComposerInstalled( $path ); + $out = Html::element( + 'h2', + [ 'id' => 'mw-version-libraries' ], + $this->msg( 'version-libraries' )->text() + ); + $out .= Html::openElement( + 'table', + [ 'class' => 'wikitable plainlinks', 'id' => 'sv-libraries' ] + ); + $out .= Html::openElement( 'tr' ) + . Html::element( 'th', [], $this->msg( 'version-libraries-library' )->text() ) + . Html::element( 'th', [], $this->msg( 'version-libraries-version' )->text() ) + . Html::element( 'th', [], $this->msg( 'version-libraries-license' )->text() ) + . Html::element( 'th', [], $this->msg( 'version-libraries-description' )->text() ) + . Html::element( 'th', [], $this->msg( 'version-libraries-authors' )->text() ) + . Html::closeElement( 'tr' ); + + foreach ( $installed->getInstalledDependencies() as $name => $info ) { + if ( strpos( $info['type'], 'mediawiki-' ) === 0 ) { + // Skip any extensions or skins since they'll be listed + // in their proper section + continue; + } + $authors = array_map( function ( $arr ) { + // If a homepage is set, link to it + if ( isset( $arr['homepage'] ) ) { + return "[{$arr['homepage']} {$arr['name']}]"; + } + return $arr['name']; + }, $info['authors'] ); + $authors = $this->listAuthors( $authors, false, "$IP/vendor/$name" ); + + // We can safely assume that the libraries' names and descriptions + // are written in English and aren't going to be translated, + // so set appropriate lang and dir attributes + $out .= Html::openElement( 'tr' ) + . Html::rawElement( + 'td', + [], + Linker::makeExternalLink( + "https://packagist.org/packages/$name", $name, + true, '', + [ 'class' => 'mw-version-library-name' ] + ) + ) + . Html::element( 'td', [ 'dir' => 'auto' ], $info['version'] ) + . Html::element( 'td', [ 'dir' => 'auto' ], $this->listToText( $info['licenses'] ) ) + . Html::element( 'td', [ 'lang' => 'en', 'dir' => 'ltr' ], $info['description'] ) + . Html::rawElement( 'td', [], $authors ) + . Html::closeElement( 'tr' ); + } + $out .= Html::closeElement( 'table' ); + + return $out; + } + + /** + * Obtains a list of installed parser tags and the associated H2 header + * + * @return string HTML output + */ + protected function getParserTags() { + global $wgParser; + + $tags = $wgParser->getTags(); + + if ( count( $tags ) ) { + $out = Html::rawElement( + 'h2', + [ + 'class' => 'mw-headline plainlinks', + 'id' => 'mw-version-parser-extensiontags', + ], + Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Tag_extensions', + $this->msg( 'version-parser-extensiontags' )->parse(), + false /* msg()->parse() already escapes */ + ) + ); + + array_walk( $tags, function ( &$value ) { + // Bidirectional isolation improves readability in RTL wikis + $value = Html::element( + 'bdi', + // Prevent < and > from slipping to another line + [ + 'style' => 'white-space: nowrap;', + ], + "<$value>" + ); + } ); + + $out .= $this->listToText( $tags ); + } else { + $out = ''; + } + + return $out; + } + + /** + * Obtains a list of installed parser function hooks and the associated H2 header + * + * @return string HTML output + */ + protected function getParserFunctionHooks() { + global $wgParser; + + $fhooks = $wgParser->getFunctionHooks(); + if ( count( $fhooks ) ) { + $out = Html::rawElement( + 'h2', + [ + 'class' => 'mw-headline plainlinks', + 'id' => 'mw-version-parser-function-hooks', + ], + Linker::makeExternalLink( + 'https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Parser_functions', + $this->msg( 'version-parser-function-hooks' )->parse(), + false /* msg()->parse() already escapes */ + ) + ); + + $out .= $this->listToText( $fhooks ); + } else { + $out = ''; + } + + return $out; + } + + /** + * Creates and returns the HTML for a single extension category. + * + * @since 1.17 + * + * @param string $type + * @param string $message + * + * @return string + */ + protected function getExtensionCategory( $type, $message ) { + global $wgExtensionCredits; + + $out = ''; + + if ( array_key_exists( $type, $wgExtensionCredits ) && count( $wgExtensionCredits[$type] ) > 0 ) { + $out .= $this->openExtType( $message, 'credits-' . $type ); + + usort( $wgExtensionCredits[$type], [ $this, 'compare' ] ); + + foreach ( $wgExtensionCredits[$type] as $extension ) { + $out .= $this->getCreditsForExtension( $type, $extension ); + } + } + + return $out; + } + + /** + * Callback to sort extensions by type. + * @param array $a + * @param array $b + * @return int + */ + public function compare( $a, $b ) { + if ( $a['name'] === $b['name'] ) { + return 0; + } else { + return $this->getLanguage()->lc( $a['name'] ) > $this->getLanguage()->lc( $b['name'] ) + ? 1 + : -1; + } + } + + /** + * Creates and formats a version line for a single extension. + * + * Information for five columns will be created. Parameters required in the + * $extension array for part rendering are indicated in () + * - The name of (name), and URL link to (url), the extension + * - Official version number (version) and if available version control system + * revision (path), link, and date + * - If available the short name of the license (license-name) and a link + * to ((LICENSE)|(COPYING))(\.txt)? if it exists. + * - Description of extension (descriptionmsg or description) + * - List of authors (author) and link to a ((AUTHORS)|(CREDITS))(\.txt)? file if it exists + * + * @param string $type Category name of the extension + * @param array $extension + * + * @return string Raw HTML + */ + public function getCreditsForExtension( $type, array $extension ) { + $out = $this->getOutput(); + + // We must obtain the information for all the bits and pieces! + // ... such as extension names and links + if ( isset( $extension['namemsg'] ) ) { + // Localized name of extension + $extensionName = $this->msg( $extension['namemsg'] )->text(); + } elseif ( isset( $extension['name'] ) ) { + // Non localized version + $extensionName = $extension['name']; + } else { + $extensionName = $this->msg( 'version-no-ext-name' )->text(); + } + + if ( isset( $extension['url'] ) ) { + $extensionNameLink = Linker::makeExternalLink( + $extension['url'], + $extensionName, + true, + '', + [ 'class' => 'mw-version-ext-name' ] + ); + } else { + $extensionNameLink = $extensionName; + } + + // ... and the version information + // If the extension path is set we will check that directory for GIT + // metadata in an attempt to extract date and vcs commit metadata. + $canonicalVersion = '–'; + $extensionPath = null; + $vcsVersion = null; + $vcsLink = null; + $vcsDate = null; + + if ( isset( $extension['version'] ) ) { + $canonicalVersion = $out->parseInline( $extension['version'] ); + } + + if ( isset( $extension['path'] ) ) { + global $IP; + $extensionPath = dirname( $extension['path'] ); + if ( $this->coreId == '' ) { + wfDebug( 'Looking up core head id' ); + $coreHeadSHA1 = self::getGitHeadSha1( $IP ); + if ( $coreHeadSHA1 ) { + $this->coreId = $coreHeadSHA1; + } + } + $cache = wfGetCache( CACHE_ANYTHING ); + $memcKey = $cache->makeKey( + 'specialversion-ext-version-text', $extension['path'], $this->coreId + ); + list( $vcsVersion, $vcsLink, $vcsDate ) = $cache->get( $memcKey ); + + if ( !$vcsVersion ) { + wfDebug( "Getting VCS info for extension {$extension['name']}" ); + $gitInfo = new GitInfo( $extensionPath ); + $vcsVersion = $gitInfo->getHeadSHA1(); + if ( $vcsVersion !== false ) { + $vcsVersion = substr( $vcsVersion, 0, 7 ); + $vcsLink = $gitInfo->getHeadViewUrl(); + $vcsDate = $gitInfo->getHeadCommitDate(); + } + $cache->set( $memcKey, [ $vcsVersion, $vcsLink, $vcsDate ], 60 * 60 * 24 ); + } else { + wfDebug( "Pulled VCS info for extension {$extension['name']} from cache" ); + } + } + + $versionString = Html::rawElement( + 'span', + [ 'class' => 'mw-version-ext-version' ], + $canonicalVersion + ); + + if ( $vcsVersion ) { + if ( $vcsLink ) { + $vcsVerString = Linker::makeExternalLink( + $vcsLink, + $this->msg( 'version-version', $vcsVersion ), + true, + '', + [ 'class' => 'mw-version-ext-vcs-version' ] + ); + } else { + $vcsVerString = Html::element( 'span', + [ 'class' => 'mw-version-ext-vcs-version' ], + "({$vcsVersion})" + ); + } + $versionString .= " {$vcsVerString}"; + + if ( $vcsDate ) { + $vcsTimeString = Html::element( 'span', + [ 'class' => 'mw-version-ext-vcs-timestamp' ], + $this->getLanguage()->timeanddate( $vcsDate, true ) + ); + $versionString .= " {$vcsTimeString}"; + } + $versionString = Html::rawElement( 'span', + [ 'class' => 'mw-version-ext-meta-version' ], + $versionString + ); + } + + // ... and license information; if a license file exists we + // will link to it + $licenseLink = ''; + if ( isset( $extension['name'] ) ) { + $licenseName = null; + if ( isset( $extension['license-name'] ) ) { + $licenseName = new HtmlArmor( $out->parseInline( $extension['license-name'] ) ); + } elseif ( $this->getExtLicenseFileName( $extensionPath ) ) { + $licenseName = $this->msg( 'version-ext-license' )->text(); + } + if ( $licenseName !== null ) { + $licenseLink = $this->getLinkRenderer()->makeLink( + $this->getPageTitle( 'License/' . $extension['name'] ), + $licenseName, + [ + 'class' => 'mw-version-ext-license', + 'dir' => 'auto', + ] + ); + } + } + + // ... and generate the description; which can be a parameterized l10n message + // in the form array( <msgname>, <parameter>, <parameter>... ) or just a straight + // up string + if ( isset( $extension['descriptionmsg'] ) ) { + // Localized description of extension + $descriptionMsg = $extension['descriptionmsg']; + + if ( is_array( $descriptionMsg ) ) { + $descriptionMsgKey = $descriptionMsg[0]; // Get the message key + array_shift( $descriptionMsg ); // Shift out the message key to get the parameters only + array_map( "htmlspecialchars", $descriptionMsg ); // For sanity + $description = $this->msg( $descriptionMsgKey, $descriptionMsg )->text(); + } else { + $description = $this->msg( $descriptionMsg )->text(); + } + } elseif ( isset( $extension['description'] ) ) { + // Non localized version + $description = $extension['description']; + } else { + $description = ''; + } + $description = $out->parseInline( $description ); + + // ... now get the authors for this extension + $authors = isset( $extension['author'] ) ? $extension['author'] : []; + $authors = $this->listAuthors( $authors, $extension['name'], $extensionPath ); + + // Finally! Create the table + $html = Html::openElement( 'tr', [ + 'class' => 'mw-version-ext', + 'id' => Sanitizer::escapeIdForAttribute( 'mw-version-ext-' . $type . '-' . $extension['name'] ) + ] + ); + + $html .= Html::rawElement( 'td', [], $extensionNameLink ); + $html .= Html::rawElement( 'td', [], $versionString ); + $html .= Html::rawElement( 'td', [], $licenseLink ); + $html .= Html::rawElement( 'td', [ 'class' => 'mw-version-ext-description' ], $description ); + $html .= Html::rawElement( 'td', [ 'class' => 'mw-version-ext-authors' ], $authors ); + + $html .= Html::closeElement( 'tr' ); + + return $html; + } + + /** + * Generate wikitext showing hooks in $wgHooks. + * + * @return string Wikitext + */ + private function getWgHooks() { + global $wgSpecialVersionShowHooks, $wgHooks; + + if ( $wgSpecialVersionShowHooks && count( $wgHooks ) ) { + $myWgHooks = $wgHooks; + ksort( $myWgHooks ); + + $ret = []; + $ret[] = '== {{int:version-hooks}} =='; + $ret[] = Html::openElement( 'table', [ 'class' => 'wikitable', 'id' => 'sv-hooks' ] ); + $ret[] = Html::openElement( 'tr' ); + $ret[] = Html::element( 'th', [], $this->msg( 'version-hook-name' )->text() ); + $ret[] = Html::element( 'th', [], $this->msg( 'version-hook-subscribedby' )->text() ); + $ret[] = Html::closeElement( 'tr' ); + + foreach ( $myWgHooks as $hook => $hooks ) { + $ret[] = Html::openElement( 'tr' ); + $ret[] = Html::element( 'td', [], $hook ); + $ret[] = Html::element( 'td', [], $this->listToText( $hooks ) ); + $ret[] = Html::closeElement( 'tr' ); + } + + $ret[] = Html::closeElement( 'table' ); + + return implode( "\n", $ret ); + } else { + return ''; + } + } + + private function openExtType( $text = null, $name = null ) { + $out = ''; + + $opt = [ 'colspan' => 5 ]; + if ( $this->firstExtOpened ) { + // Insert a spacing line + $out .= Html::rawElement( 'tr', [ 'class' => 'sv-space' ], + Html::element( 'td', $opt ) + ); + } + $this->firstExtOpened = true; + + if ( $name ) { + $opt['id'] = "sv-$name"; + } + + if ( $text !== null ) { + $out .= Html::rawElement( 'tr', [], + Html::element( 'th', $opt, $text ) + ); + } + + $firstHeadingMsg = ( $name === 'credits-skin' ) + ? 'version-skin-colheader-name' + : 'version-ext-colheader-name'; + $out .= Html::openElement( 'tr' ); + $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ], + $this->msg( $firstHeadingMsg )->text() ); + $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ], + $this->msg( 'version-ext-colheader-version' )->text() ); + $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ], + $this->msg( 'version-ext-colheader-license' )->text() ); + $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ], + $this->msg( 'version-ext-colheader-description' )->text() ); + $out .= Html::element( 'th', [ 'class' => 'mw-version-ext-col-label' ], + $this->msg( 'version-ext-colheader-credits' )->text() ); + $out .= Html::closeElement( 'tr' ); + + return $out; + } + + /** + * Get information about client's IP address. + * + * @return string HTML fragment + */ + private function IPInfo() { + $ip = str_replace( '--', ' - ', htmlspecialchars( $this->getRequest()->getIP() ) ); + + return "<!-- visited from $ip -->\n<span style='display:none'>visited from $ip</span>"; + } + + /** + * Return a formatted unsorted list of authors + * + * 'And Others' + * If an item in the $authors array is '...' it is assumed to indicate an + * 'and others' string which will then be linked to an ((AUTHORS)|(CREDITS))(\.txt)? + * file if it exists in $dir. + * + * Similarly an entry ending with ' ...]' is assumed to be a link to an + * 'and others' page. + * + * If no '...' string variant is found, but an authors file is found an + * 'and others' will be added to the end of the credits. + * + * @param string|array $authors + * @param string|bool $extName Name of the extension for link creation, + * false if no links should be created + * @param string $extDir Path to the extension root directory + * + * @return string HTML fragment + */ + public function listAuthors( $authors, $extName, $extDir ) { + $hasOthers = false; + $linkRenderer = $this->getLinkRenderer(); + + $list = []; + foreach ( (array)$authors as $item ) { + if ( $item == '...' ) { + $hasOthers = true; + + if ( $extName && $this->getExtAuthorsFileName( $extDir ) ) { + $text = $linkRenderer->makeLink( + $this->getPageTitle( "Credits/$extName" ), + $this->msg( 'version-poweredby-others' )->text() + ); + } else { + $text = $this->msg( 'version-poweredby-others' )->escaped(); + } + $list[] = $text; + } elseif ( substr( $item, -5 ) == ' ...]' ) { + $hasOthers = true; + $list[] = $this->getOutput()->parseInline( + substr( $item, 0, -4 ) . $this->msg( 'version-poweredby-others' )->text() . "]" + ); + } else { + $list[] = $this->getOutput()->parseInline( $item ); + } + } + + if ( $extName && !$hasOthers && $this->getExtAuthorsFileName( $extDir ) ) { + $list[] = $text = $linkRenderer->makeLink( + $this->getPageTitle( "Credits/$extName" ), + $this->msg( 'version-poweredby-others' )->text() + ); + } + + return $this->listToText( $list, false ); + } + + /** + * Obtains the full path of an extensions authors or credits file if + * one exists. + * + * @param string $extDir Path to the extensions root directory + * + * @since 1.23 + * + * @return bool|string False if no such file exists, otherwise returns + * a path to it. + */ + public static function getExtAuthorsFileName( $extDir ) { + if ( !$extDir ) { + return false; + } + + foreach ( scandir( $extDir ) as $file ) { + $fullPath = $extDir . DIRECTORY_SEPARATOR . $file; + if ( preg_match( '/^((AUTHORS)|(CREDITS))(\.txt|\.wiki|\.mediawiki)?$/', $file ) && + is_readable( $fullPath ) && + is_file( $fullPath ) + ) { + return $fullPath; + } + } + + return false; + } + + /** + * Obtains the full path of an extensions copying or license file if + * one exists. + * + * @param string $extDir Path to the extensions root directory + * + * @since 1.23 + * + * @return bool|string False if no such file exists, otherwise returns + * a path to it. + */ + public static function getExtLicenseFileName( $extDir ) { + if ( !$extDir ) { + return false; + } + + foreach ( scandir( $extDir ) as $file ) { + $fullPath = $extDir . DIRECTORY_SEPARATOR . $file; + if ( preg_match( '/^((COPYING)|(LICENSE))(\.txt)?$/', $file ) && + is_readable( $fullPath ) && + is_file( $fullPath ) + ) { + return $fullPath; + } + } + + return false; + } + + /** + * Convert an array of items into a list for display. + * + * @param array $list List of elements to display + * @param bool $sort Whether to sort the items in $list + * + * @return string + */ + public function listToText( $list, $sort = true ) { + if ( !count( $list ) ) { + return ''; + } + if ( $sort ) { + sort( $list ); + } + + return $this->getLanguage() + ->listToText( array_map( [ __CLASS__, 'arrayToString' ], $list ) ); + } + + /** + * Convert an array or object to a string for display. + * + * @param mixed $list Will convert an array to string if given and return + * the parameter unaltered otherwise + * + * @return mixed + */ + public static function arrayToString( $list ) { + if ( is_array( $list ) && count( $list ) == 1 ) { + $list = $list[0]; + } + if ( $list instanceof Closure ) { + // Don't output stuff like "Closure$;1028376090#8$48499d94fe0147f7c633b365be39952b$" + return 'Closure'; + } elseif ( is_object( $list ) ) { + $class = wfMessage( 'parentheses' )->params( get_class( $list ) )->escaped(); + + return $class; + } elseif ( !is_array( $list ) ) { + return $list; + } else { + if ( is_object( $list[0] ) ) { + $class = get_class( $list[0] ); + } else { + $class = $list[0]; + } + + return wfMessage( 'parentheses' )->params( "$class, {$list[1]}" )->escaped(); + } + } + + /** + * @param string $dir Directory of the git checkout + * @return bool|string Sha1 of commit HEAD points to + */ + public static function getGitHeadSha1( $dir ) { + $repo = new GitInfo( $dir ); + + return $repo->getHeadSHA1(); + } + + /** + * @param string $dir Directory of the git checkout + * @return bool|string Branch currently checked out + */ + public static function getGitCurrentBranch( $dir ) { + $repo = new GitInfo( $dir ); + return $repo->getCurrentBranch(); + } + + /** + * Get the list of entry points and their URLs + * @return string Wikitext + */ + public function getEntryPointInfo() { + global $wgArticlePath, $wgScriptPath; + $scriptPath = $wgScriptPath ? $wgScriptPath : "/"; + $entryPoints = [ + 'version-entrypoints-articlepath' => $wgArticlePath, + 'version-entrypoints-scriptpath' => $scriptPath, + 'version-entrypoints-index-php' => wfScript( 'index' ), + 'version-entrypoints-api-php' => wfScript( 'api' ), + 'version-entrypoints-load-php' => wfScript( 'load' ), + ]; + + $language = $this->getLanguage(); + $thAttribures = [ + 'dir' => $language->getDir(), + 'lang' => $language->getHtmlCode() + ]; + $out = Html::element( + 'h2', + [ 'id' => 'mw-version-entrypoints' ], + $this->msg( 'version-entrypoints' )->text() + ) . + Html::openElement( 'table', + [ + 'class' => 'wikitable plainlinks', + 'id' => 'mw-version-entrypoints-table', + 'dir' => 'ltr', + 'lang' => 'en' + ] + ) . + Html::openElement( 'tr' ) . + Html::element( + 'th', + $thAttribures, + $this->msg( 'version-entrypoints-header-entrypoint' )->text() + ) . + Html::element( + 'th', + $thAttribures, + $this->msg( 'version-entrypoints-header-url' )->text() + ) . + Html::closeElement( 'tr' ); + + foreach ( $entryPoints as $message => $value ) { + $url = wfExpandUrl( $value, PROTO_RELATIVE ); + $out .= Html::openElement( 'tr' ) . + // ->text() looks like it should be ->parse(), but this function + // returns wikitext, not HTML, boo + Html::rawElement( 'td', [], $this->msg( $message )->text() ) . + Html::rawElement( 'td', [], Html::rawElement( 'code', [], "[$url $value]" ) ) . + Html::closeElement( 'tr' ); + } + + $out .= Html::closeElement( 'table' ); + + return $out; + } + + protected function getGroupName() { + return 'wiki'; + } +} diff --git a/www/wiki/includes/specials/SpecialWantedcategories.php b/www/wiki/includes/specials/SpecialWantedcategories.php new file mode 100644 index 00000000..fc0c3123 --- /dev/null +++ b/www/wiki/includes/specials/SpecialWantedcategories.php @@ -0,0 +1,131 @@ +<?php +/** + * Implements Special:Wantedcategories + * + * Copyright © 2005 Ævar Arnfjörð Bjarmason + * + * 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 + */ + +/** + * A querypage to list the most wanted categories - implements Special:Wantedcategories + * + * @ingroup SpecialPage + */ +class WantedCategoriesPage extends WantedQueryPage { + private $currentCategoryCounts; + + function __construct( $name = 'Wantedcategories' ) { + parent::__construct( $name ); + } + + function getQueryInfo() { + return [ + 'tables' => [ 'categorylinks', 'page' ], + 'fields' => [ + 'namespace' => NS_CATEGORY, + 'title' => 'cl_to', + 'value' => 'COUNT(*)' + ], + 'conds' => [ 'page_title IS NULL' ], + 'options' => [ 'GROUP BY' => 'cl_to' ], + 'join_conds' => [ 'page' => [ 'LEFT JOIN', + [ 'page_title = cl_to', + 'page_namespace' => NS_CATEGORY ] ] ] + ]; + } + + function preprocessResults( $db, $res ) { + parent::preprocessResults( $db, $res ); + + $this->currentCategoryCounts = []; + + if ( !$res->numRows() || !$this->isCached() ) { + return; + } + + // Fetch (hopefully) up-to-date numbers of pages in each category. + // This should be fast enough as we limit the list to a reasonable length. + + $allCategories = []; + foreach ( $res as $row ) { + $allCategories[] = $row->title; + } + + $categoryRes = $db->select( + 'category', + [ 'cat_title', 'cat_pages' ], + [ 'cat_title' => $allCategories ], + __METHOD__ + ); + foreach ( $categoryRes as $row ) { + $this->currentCategoryCounts[$row->cat_title] = intval( $row->cat_pages ); + } + + // Back to start for display + $res->seek( 0 ); + } + + /** + * @param Skin $skin + * @param object $result Result row + * @return string + */ + function formatResult( $skin, $result ) { + global $wgContLang; + + $nt = Title::makeTitle( $result->namespace, $result->title ); + $text = $wgContLang->convert( $nt->getText() ); + + if ( !$this->isCached() ) { + // We can assume the freshest data + $plink = $this->getLinkRenderer()->makeBrokenLink( + $nt, + $text + ); + $nlinks = $this->msg( 'nmembers' )->numParams( $result->value )->escaped(); + } else { + $plink = $this->getLinkRenderer()->makeLink( $nt, $text ); + + $currentValue = isset( $this->currentCategoryCounts[$result->title] ) + ? $this->currentCategoryCounts[$result->title] + : 0; + $cachedValue = intval( $result->value ); // T76910 + + // If the category has been created or emptied since the list was refreshed, strike it + if ( $nt->isKnown() || $currentValue === 0 ) { + $plink = "<del>$plink</del>"; + } + + // Show the current number of category entries if it changed + if ( $currentValue !== $cachedValue ) { + $nlinks = $this->msg( 'nmemberschanged' ) + ->numParams( $cachedValue, $currentValue )->escaped(); + } else { + $nlinks = $this->msg( 'nmembers' )->numParams( $cachedValue )->escaped(); + } + } + + return $this->getLanguage()->specialList( $plink, $nlinks ); + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialWantedfiles.php b/www/wiki/includes/specials/SpecialWantedfiles.php new file mode 100644 index 00000000..2ebbc2d8 --- /dev/null +++ b/www/wiki/includes/specials/SpecialWantedfiles.php @@ -0,0 +1,153 @@ +<?php +/** + * Implements Special:Wantedfiles + * + * Copyright © 2008 Soxred93 + * + * 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 + * @author Soxred93 <soxred93@gmail.com> + */ + +/** + * Querypage that lists the most wanted files + * + * @ingroup SpecialPage + */ +class WantedFilesPage extends WantedQueryPage { + + function __construct( $name = 'Wantedfiles' ) { + parent::__construct( $name ); + } + + function getPageHeader() { + # Specifically setting to use "Wanted Files" (NS_MAIN) as title, so as to get what + # category would be used on main namespace pages, for those tricky wikipedia + # admins who like to do {{#ifeq:{{NAMESPACE}}|foo|bar|....}}. + $catMessage = $this->msg( 'broken-file-category' ) + ->title( Title::newFromText( "Wanted Files", NS_MAIN ) ) + ->inContentLanguage(); + + if ( !$catMessage->isDisabled() ) { + $category = Title::makeTitleSafe( NS_CATEGORY, $catMessage->text() ); + } else { + $category = false; + } + + $noForeign = ''; + if ( !$this->likelyToHaveFalsePositives() ) { + // Additional messages for grep: + // wantedfiletext-cat-noforeign, wantedfiletext-nocat-noforeign + $noForeign = '-noforeign'; + } + + if ( $category ) { + return $this + ->msg( 'wantedfiletext-cat' . $noForeign ) + ->params( $category->getFullText() ) + ->parseAsBlock(); + } else { + return $this + ->msg( 'wantedfiletext-nocat' . $noForeign ) + ->parseAsBlock(); + } + } + + /** + * Whether foreign repos are likely to cause false positives + * + * In its own function to allow subclasses to override. + * @see SpecialWantedFilesGUOverride in GlobalUsage extension. + * @since 1.24 + * @return bool + */ + protected function likelyToHaveFalsePositives() { + return RepoGroup::singleton()->hasForeignRepos(); + } + + /** + * KLUGE: The results may contain false positives for files + * that exist e.g. in a shared repo. Setting this at least + * keeps them from showing up as redlinks in the output, even + * if it doesn't fix the real problem (T8220). + * + * @note could also have existing links here from broken file + * redirects. + * @return bool + */ + function forceExistenceCheck() { + return true; + } + + /** + * Does the file exist? + * + * Use wfFindFile so we still think file namespace pages without + * files are missing, but valid file redirects and foreign files are ok. + * + * @param Title $title + * @return bool + */ + protected function existenceCheck( Title $title ) { + return (bool)wfFindFile( $title ); + } + + function getQueryInfo() { + return [ + 'tables' => [ + 'imagelinks', + 'page', + 'redirect', + 'img1' => 'image', + 'img2' => 'image', + ], + 'fields' => [ + 'namespace' => NS_FILE, + 'title' => 'il_to', + 'value' => 'COUNT(*)' + ], + 'conds' => [ + 'img1.img_name' => null, + // We also need to exclude file redirects + 'img2.img_name' => null, + ], + 'options' => [ 'GROUP BY' => 'il_to' ], + 'join_conds' => [ + 'img1' => [ 'LEFT JOIN', + 'il_to = img1.img_name' + ], + 'page' => [ 'LEFT JOIN', [ + 'il_to = page_title', + 'page_namespace' => NS_FILE, + ] ], + 'redirect' => [ 'LEFT JOIN', [ + 'page_id = rd_from', + 'rd_namespace' => NS_FILE, + 'rd_interwiki' => '' + ] ], + 'img2' => [ 'LEFT JOIN', + 'rd_title = img2.img_name' + ] + ] + ]; + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialWantedpages.php b/www/wiki/includes/specials/SpecialWantedpages.php new file mode 100644 index 00000000..8cea6ccb --- /dev/null +++ b/www/wiki/includes/specials/SpecialWantedpages.php @@ -0,0 +1,98 @@ +<?php +/** + * Implements Special:Wantedpages + * + * 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 + */ + +/** + * A special page that lists most linked pages that does not exist + * + * @ingroup SpecialPage + */ +class WantedPagesPage extends WantedQueryPage { + + function __construct( $name = 'Wantedpages' ) { + parent::__construct( $name ); + } + + function isIncludable() { + return true; + } + + function execute( $par ) { + $inc = $this->including(); + + if ( $inc ) { + $this->limit = (int)$par; + $this->offset = 0; + } + $this->setListoutput( $inc ); + $this->shownavigation = !$inc; + parent::execute( $par ); + } + + function getQueryInfo() { + $dbr = wfGetDB( DB_REPLICA ); + $count = $this->getConfig()->get( 'WantedPagesThreshold' ) - 1; + $query = [ + 'tables' => [ + 'pagelinks', + 'pg1' => 'page', + 'pg2' => 'page' + ], + 'fields' => [ + 'namespace' => 'pl_namespace', + 'title' => 'pl_title', + 'value' => 'COUNT(*)' + ], + 'conds' => [ + 'pg1.page_namespace IS NULL', + 'pl_namespace NOT IN (' . $dbr->makeList( [ NS_USER, NS_USER_TALK ] ) . ')', + 'pg2.page_namespace != ' . $dbr->addQuotes( NS_MEDIAWIKI ), + ], + 'options' => [ + 'HAVING' => [ + 'COUNT(*) > ' . $dbr->addQuotes( $count ), + 'COUNT(*) > SUM(pg2.page_is_redirect)' + ], + 'GROUP BY' => [ 'pl_namespace', 'pl_title' ] + ], + 'join_conds' => [ + 'pg1' => [ + 'LEFT JOIN', [ + 'pg1.page_namespace = pl_namespace', + 'pg1.page_title = pl_title' + ] + ], + 'pg2' => [ 'LEFT JOIN', 'pg2.page_id = pl_from' ] + ] + ]; + // Replacement for the WantedPages::getSQL hook + // Avoid PHP 7.1 warning from passing $this by reference + $wantedPages = $this; + Hooks::run( 'WantedPages::getQueryInfo', [ &$wantedPages, &$query ] ); + + return $query; + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialWantedtemplates.php b/www/wiki/includes/specials/SpecialWantedtemplates.php new file mode 100644 index 00000000..66e68142 --- /dev/null +++ b/www/wiki/includes/specials/SpecialWantedtemplates.php @@ -0,0 +1,61 @@ +<?php +/** + * Implements Special:Wantedtemplates + * + * Copyright © 2008, Danny B. + * Based on SpecialWantedcategories.php by Ævar Arnfjörð Bjarmason <avarab@gmail.com> + * makeWlhLink() taken from SpecialMostlinkedtemplates by Rob Church <robchur@gmail.com> + * + * 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 + * @author Danny B. + */ + +/** + * A querypage to list the most wanted templates + * + * @ingroup SpecialPage + */ +class WantedTemplatesPage extends WantedQueryPage { + function __construct( $name = 'Wantedtemplates' ) { + parent::__construct( $name ); + } + + function getQueryInfo() { + return [ + 'tables' => [ 'templatelinks', 'page' ], + 'fields' => [ + 'namespace' => 'tl_namespace', + 'title' => 'tl_title', + 'value' => 'COUNT(*)' + ], + 'conds' => [ + 'page_title IS NULL', + 'tl_namespace' => NS_TEMPLATE + ], + 'options' => [ 'GROUP BY' => [ 'tl_namespace', 'tl_title' ] ], + 'join_conds' => [ 'page' => [ 'LEFT JOIN', + [ 'page_namespace = tl_namespace', + 'page_title = tl_title' ] ] ] + ]; + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/SpecialWatchlist.php b/www/wiki/includes/specials/SpecialWatchlist.php new file mode 100644 index 00000000..c266a80e --- /dev/null +++ b/www/wiki/includes/specials/SpecialWatchlist.php @@ -0,0 +1,872 @@ +<?php +/** + * Implements Special:Watchlist + * + * 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 + */ + +use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +/** + * A special page that lists last changes made to the wiki, + * limited to user-defined list of titles. + * + * @ingroup SpecialPage + */ +class SpecialWatchlist extends ChangesListSpecialPage { + protected static $savedQueriesPreferenceName = 'rcfilters-wl-saved-queries'; + protected static $daysPreferenceName = 'watchlistdays'; + protected static $limitPreferenceName = 'wllimit'; + + private $maxDays; + + public function __construct( $page = 'Watchlist', $restriction = 'viewmywatchlist' ) { + parent::__construct( $page, $restriction ); + + $this->maxDays = $this->getConfig()->get( 'RCMaxAge' ) / ( 3600 * 24 ); + } + + public function doesWrites() { + return true; + } + + /** + * Main execution point + * + * @param string $subpage + */ + function execute( $subpage ) { + // Anons don't get a watchlist + $this->requireLogin( 'watchlistanontext' ); + + $output = $this->getOutput(); + $request = $this->getRequest(); + $this->addHelpLink( 'Help:Watching pages' ); + $output->addModules( [ + 'mediawiki.special.changeslist.visitedstatus', + 'mediawiki.special.watchlist', + ] ); + $output->addModuleStyles( [ 'mediawiki.special.watchlist.styles' ] ); + + $mode = SpecialEditWatchlist::getMode( $request, $subpage ); + if ( $mode !== false ) { + if ( $mode === SpecialEditWatchlist::EDIT_RAW ) { + $title = SpecialPage::getTitleFor( 'EditWatchlist', 'raw' ); + } elseif ( $mode === SpecialEditWatchlist::EDIT_CLEAR ) { + $title = SpecialPage::getTitleFor( 'EditWatchlist', 'clear' ); + } else { + $title = SpecialPage::getTitleFor( 'EditWatchlist' ); + } + + $output->redirect( $title->getLocalURL() ); + + return; + } + + $this->checkPermissions(); + + $user = $this->getUser(); + $opts = $this->getOptions(); + + $config = $this->getConfig(); + if ( ( $config->get( 'EnotifWatchlist' ) || $config->get( 'ShowUpdatedMarker' ) ) + && $request->getVal( 'reset' ) + && $request->wasPosted() + && $user->matchEditToken( $request->getVal( 'token' ) ) + ) { + $user->clearAllNotifications(); + $output->redirect( $this->getPageTitle()->getFullURL( $opts->getChangedValues() ) ); + + return; + } + + parent::execute( $subpage ); + + if ( $this->isStructuredFilterUiEnabled() ) { + $output->addModuleStyles( [ 'mediawiki.rcfilters.highlightCircles.seenunseen.styles' ] ); + + $output->addJsConfigVars( + 'wgStructuredChangeFiltersEditWatchlistUrl', + SpecialPage::getTitleFor( 'EditWatchlist' )->getLocalURL() + ); + } + } + + public static function checkStructuredFilterUiEnabled( Config $config, User $user ) { + return ( + $config->get( 'StructuredChangeFiltersOnWatchlist' ) && + $user->getOption( 'rcenhancedfilters' ) + ); + } + + /** + * Return an array of subpages that this special page will accept. + * + * @see also SpecialEditWatchlist::getSubpagesForPrefixSearch + * @return string[] subpages + */ + public function getSubpagesForPrefixSearch() { + return [ + 'clear', + 'edit', + 'raw', + ]; + } + + /** + * @inheritDoc + */ + protected function transformFilterDefinition( array $filterDefinition ) { + if ( isset( $filterDefinition['showHideSuffix'] ) ) { + $filterDefinition['showHide'] = 'wl' . $filterDefinition['showHideSuffix']; + } + + return $filterDefinition; + } + + /** + * @inheritDoc + */ + protected function registerFilters() { + parent::registerFilters(); + + // legacy 'extended' filter + $this->registerFilterGroup( new ChangesListBooleanFilterGroup( [ + 'name' => 'extended-group', + 'filters' => [ + [ + 'name' => 'extended', + 'isReplacedInStructuredUi' => true, + 'activeValue' => false, + 'default' => $this->getUser()->getBoolOption( 'extendwatchlist' ), + 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, + &$fields, &$conds, &$query_options, &$join_conds ) { + $nonRevisionTypes = [ RC_LOG ]; + Hooks::run( 'SpecialWatchlistGetNonRevisionTypes', [ &$nonRevisionTypes ] ); + if ( $nonRevisionTypes ) { + $conds[] = $dbr->makeList( + [ + 'rc_this_oldid=page_latest', + 'rc_type' => $nonRevisionTypes, + ], + LIST_OR + ); + } + }, + ] + ], + + ] ) ); + + if ( $this->isStructuredFilterUiEnabled() ) { + $this->getFilterGroup( 'lastRevision' ) + ->getFilter( 'hidepreviousrevisions' ) + ->setDefault( !$this->getUser()->getBoolOption( 'extendwatchlist' ) ); + } + + $this->registerFilterGroup( new ChangesListStringOptionsFilterGroup( [ + 'name' => 'watchlistactivity', + 'title' => 'rcfilters-filtergroup-watchlistactivity', + 'class' => ChangesListStringOptionsFilterGroup::class, + 'priority' => 3, + 'isFullCoverage' => true, + 'filters' => [ + [ + 'name' => 'unseen', + 'label' => 'rcfilters-filter-watchlistactivity-unseen-label', + 'description' => 'rcfilters-filter-watchlistactivity-unseen-description', + 'cssClassSuffix' => 'watchedunseen', + 'isRowApplicableCallable' => function ( $ctx, $rc ) { + $changeTs = $rc->getAttribute( 'rc_timestamp' ); + $lastVisitTs = $rc->getAttribute( 'wl_notificationtimestamp' ); + return $lastVisitTs !== null && $changeTs >= $lastVisitTs; + }, + ], + [ + 'name' => 'seen', + 'label' => 'rcfilters-filter-watchlistactivity-seen-label', + 'description' => 'rcfilters-filter-watchlistactivity-seen-description', + 'cssClassSuffix' => 'watchedseen', + 'isRowApplicableCallable' => function ( $ctx, $rc ) { + $changeTs = $rc->getAttribute( 'rc_timestamp' ); + $lastVisitTs = $rc->getAttribute( 'wl_notificationtimestamp' ); + return $lastVisitTs === null || $changeTs < $lastVisitTs; + } + ], + ], + 'default' => ChangesListStringOptionsFilterGroup::NONE, + 'queryCallable' => function ( $specialPageClassName, $context, $dbr, + &$tables, &$fields, &$conds, &$query_options, &$join_conds, $selectedValues ) { + if ( $selectedValues === [ 'seen' ] ) { + $conds[] = $dbr->makeList( [ + 'wl_notificationtimestamp IS NULL', + 'rc_timestamp < wl_notificationtimestamp' + ], LIST_OR ); + } elseif ( $selectedValues === [ 'unseen' ] ) { + $conds[] = $dbr->makeList( [ + 'wl_notificationtimestamp IS NOT NULL', + 'rc_timestamp >= wl_notificationtimestamp' + ], LIST_AND ); + } + } + ] ) ); + + $user = $this->getUser(); + + $significance = $this->getFilterGroup( 'significance' ); + $hideMinor = $significance->getFilter( 'hideminor' ); + $hideMinor->setDefault( $user->getBoolOption( 'watchlisthideminor' ) ); + + $automated = $this->getFilterGroup( 'automated' ); + $hideBots = $automated->getFilter( 'hidebots' ); + $hideBots->setDefault( $user->getBoolOption( 'watchlisthidebots' ) ); + + $registration = $this->getFilterGroup( 'registration' ); + $hideAnons = $registration->getFilter( 'hideanons' ); + $hideAnons->setDefault( $user->getBoolOption( 'watchlisthideanons' ) ); + $hideLiu = $registration->getFilter( 'hideliu' ); + $hideLiu->setDefault( $user->getBoolOption( 'watchlisthideliu' ) ); + + // Selecting both hideanons and hideliu on watchlist preferances + // gives mutually exclusive filters, so those are ignored + if ( $user->getBoolOption( 'watchlisthideanons' ) && + !$user->getBoolOption( 'watchlisthideliu' ) + ) { + $this->getFilterGroup( 'userExpLevel' ) + ->setDefault( 'registered' ); + } + + if ( $user->getBoolOption( 'watchlisthideliu' ) && + !$user->getBoolOption( 'watchlisthideanons' ) + ) { + $this->getFilterGroup( 'userExpLevel' ) + ->setDefault( 'unregistered' ); + } + + $reviewStatus = $this->getFilterGroup( 'reviewStatus' ); + if ( $reviewStatus !== null ) { + // Conditional on feature being available and rights + if ( $user->getBoolOption( 'watchlisthidepatrolled' ) ) { + $reviewStatus->setDefault( 'unpatrolled' ); + $legacyReviewStatus = $this->getFilterGroup( 'legacyReviewStatus' ); + $legacyHidePatrolled = $legacyReviewStatus->getFilter( 'hidepatrolled' ); + $legacyHidePatrolled->setDefault( true ); + } + } + + $authorship = $this->getFilterGroup( 'authorship' ); + $hideMyself = $authorship->getFilter( 'hidemyself' ); + $hideMyself->setDefault( $user->getBoolOption( 'watchlisthideown' ) ); + + $changeType = $this->getFilterGroup( 'changeType' ); + $hideCategorization = $changeType->getFilter( 'hidecategorization' ); + if ( $hideCategorization !== null ) { + // Conditional on feature being available + $hideCategorization->setDefault( $user->getBoolOption( 'watchlisthidecategorization' ) ); + } + } + + /** + * Get all custom filters + * + * @return array Map of filter URL param names to properties (msg/default) + */ + protected function getCustomFilters() { + if ( $this->customFilters === null ) { + $this->customFilters = parent::getCustomFilters(); + Hooks::run( 'SpecialWatchlistFilters', [ $this, &$this->customFilters ], '1.23' ); + } + + return $this->customFilters; + } + + /** + * Fetch values for a FormOptions object from the WebRequest associated with this instance. + * + * Maps old pre-1.23 request parameters Watchlist used to use (different from Recentchanges' ones) + * to the current ones. + * + * @param FormOptions $opts + * @return FormOptions + */ + protected function fetchOptionsFromRequest( $opts ) { + static $compatibilityMap = [ + 'hideMinor' => 'hideminor', + 'hideBots' => 'hidebots', + 'hideAnons' => 'hideanons', + 'hideLiu' => 'hideliu', + 'hidePatrolled' => 'hidepatrolled', + 'hideOwn' => 'hidemyself', + ]; + + $params = $this->getRequest()->getValues(); + foreach ( $compatibilityMap as $from => $to ) { + if ( isset( $params[$from] ) ) { + $params[$to] = $params[$from]; + unset( $params[$from] ); + } + } + + if ( $this->getRequest()->getVal( 'action' ) == 'submit' ) { + $allBooleansFalse = []; + + // If the user submitted the form, start with a baseline of "all + // booleans are false", then change the ones they checked. This + // means we ignore the defaults. + + // This is how we handle the fact that HTML forms don't submit + // unchecked boxes. + foreach ( $this->getLegacyShowHideFilters() as $filter ) { + $allBooleansFalse[ $filter->getName() ] = false; + } + + $params = $params + $allBooleansFalse; + } + + // Not the prettiest way to achieve this… FormOptions internally depends on data sanitization + // methods defined on WebRequest and removing this dependency would cause some code duplication. + $request = new DerivativeRequest( $this->getRequest(), $params ); + $opts->fetchValuesFromRequest( $request ); + + return $opts; + } + + /** + * @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'], [ 'watchlist' ] ); + $fields = array_merge( $rcQuery['fields'], $fields ); + + $join_conds = array_merge( + [ + 'watchlist' => [ + 'INNER JOIN', + [ + 'wl_user' => $user->getId(), + 'wl_namespace=rc_namespace', + 'wl_title=rc_title' + ], + ], + ], + $rcQuery['joins'], + $join_conds + ); + + $tables[] = 'page'; + $fields[] = 'page_latest'; + $join_conds['page'] = [ 'LEFT JOIN', 'rc_cur_id=page_id' ]; + + $fields[] = 'wl_notificationtimestamp'; + + // Log entries with DELETED_ACTION must not show up unless the user has + // the necessary rights. + if ( !$user->isAllowed( 'deletedhistory' ) ) { + $bitmask = LogPage::DELETED_ACTION; + } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { + $bitmask = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED; + } else { + $bitmask = 0; + } + if ( $bitmask ) { + $conds[] = $dbr->makeList( [ + 'rc_type != ' . RC_LOG, + $dbr->bitAnd( 'rc_deleted', $bitmask ) . " != $bitmask", + ], LIST_OR ); + } + + $tagFilter = $opts['tagfilter'] ? explode( '|', $opts['tagfilter'] ) : []; + ChangeTags::modifyDisplayQuery( + $tables, + $fields, + $conds, + $join_conds, + $query_options, + $tagFilter + ); + + $this->runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, $opts ); + + 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) + $query_options = array_merge( $orderByAndLimit, $query_options ); + + return $dbr->select( + $tables, + $fields, + $conds, + __METHOD__, + $query_options, + $join_conds + ); + } + + protected function runMainQueryHook( &$tables, &$fields, &$conds, &$query_options, + &$join_conds, $opts + ) { + return parent::runMainQueryHook( $tables, $fields, $conds, $query_options, $join_conds, $opts ) + && Hooks::run( + 'SpecialWatchlistQuery', + [ &$conds, &$tables, &$join_conds, &$fields, $opts ], + '1.23' + ); + } + + /** + * Return a IDatabase object for reading + * + * @return IDatabase + */ + protected function getDB() { + return wfGetDB( DB_REPLICA, 'watchlist' ); + } + + /** + * Output feed links. + */ + public function outputFeedLinks() { + $user = $this->getUser(); + $wlToken = $user->getTokenFromOption( 'watchlisttoken' ); + if ( $wlToken ) { + $this->addFeedLinks( [ + 'action' => 'feedwatchlist', + 'allrev' => 1, + 'wlowner' => $user->getName(), + 'wltoken' => $wlToken, + ] ); + } + } + + /** + * Build and output the actual changes list. + * + * @param IResultWrapper $rows Database rows + * @param FormOptions $opts + */ + public function outputChangesList( $rows, $opts ) { + $dbr = $this->getDB(); + $user = $this->getUser(); + $output = $this->getOutput(); + + # Show a message about replica DB lag, if applicable + $lag = MediaWikiServices::getInstance()->getDBLoadBalancer()->safeGetLag( $dbr ); + if ( $lag > 0 ) { + $output->showLagWarning( $lag ); + } + + # If no rows to display, show message before try to render the list + if ( $rows->numRows() == 0 ) { + $output->wrapWikiMsg( + "<div class='mw-changeslist-empty'>\n$1\n</div>", 'recentchanges-noresult' + ); + return; + } + + $dbr->dataSeek( $rows, 0 ); + + $list = ChangesList::newFromContext( $this->getContext(), $this->filterGroups ); + $list->setWatchlistDivs(); + $list->initChangesListRows( $rows ); + if ( $user->getOption( 'watchlistunwatchlinks' ) ) { + $list->setChangeLinePrefixer( function ( RecentChange $rc, ChangesList $cl, $grouped ) { + // Don't show unwatch link if the line is a grouped log entry using EnhancedChangesList, + // since EnhancedChangesList groups log entries by performer rather than by target article + if ( $rc->mAttribs['rc_type'] == RC_LOG && $cl instanceof EnhancedChangesList && + $grouped ) { + return ''; + } else { + return $this->getLinkRenderer() + ->makeKnownLink( $rc->getTitle(), + $this->msg( 'watchlist-unwatch' )->text(), [ + 'class' => 'mw-unwatch-link', + 'title' => $this->msg( 'tooltip-ca-unwatch' )->text() + ], [ 'action' => 'unwatch' ] ) . ' '; + } + } ); + } + $dbr->dataSeek( $rows, 0 ); + + if ( $this->getConfig()->get( 'RCShowWatchingUsers' ) + && $user->getOption( 'shownumberswatching' ) + ) { + $watchedItemStore = MediaWikiServices::getInstance()->getWatchedItemStore(); + } + + $s = $list->beginRecentChangesList(); + + if ( $this->isStructuredFilterUiEnabled() ) { + $s .= $this->makeLegend(); + } + + $userShowHiddenCats = $this->getUser()->getBoolOption( 'showhiddencats' ); + $counter = 1; + foreach ( $rows as $obj ) { + # Make RC entry + $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++; + + if ( $this->getConfig()->get( 'ShowUpdatedMarker' ) ) { + $updated = $obj->wl_notificationtimestamp; + } else { + $updated = false; + } + + if ( isset( $watchedItemStore ) ) { + $rcTitleValue = new TitleValue( (int)$obj->rc_namespace, $obj->rc_title ); + $rc->numberofWatchingusers = $watchedItemStore->countWatchers( $rcTitleValue ); + } else { + $rc->numberofWatchingusers = 0; + } + + $changeLine = $list->recentChangesLine( $rc, $updated, $counter ); + if ( $changeLine !== false ) { + $s .= $changeLine; + } + } + $s .= $list->endRecentChangesList(); + + $output->addHTML( $s ); + } + + /** + * 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 ) { + $user = $this->getUser(); + $out = $this->getOutput(); + + $out->addSubtitle( + $this->msg( 'watchlistfor2', $user->getName() ) + ->rawParams( SpecialEditWatchlist::buildTools( + $this->getLanguage(), + $this->getLinkRenderer() + ) ) + ); + + $this->setTopText( $opts ); + + $form = ''; + + $form .= Xml::openElement( 'form', [ + 'method' => 'get', + 'action' => wfScript(), + 'id' => 'mw-watchlist-form' + ] ); + $form .= Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ); + $form .= Xml::openElement( + 'fieldset', + [ 'id' => 'mw-watchlist-options', 'class' => 'cloptions' ] + ); + $form .= Xml::element( + 'legend', null, $this->msg( 'watchlist-options' )->text() + ); + + if ( !$this->isStructuredFilterUiEnabled() ) { + $form .= $this->makeLegend(); + } + + $lang = $this->getLanguage(); + $timestamp = wfTimestampNow(); + $wlInfo = Html::rawElement( + 'span', + [ + 'class' => 'wlinfo', + 'data-params' => json_encode( [ 'from' => $timestamp ] ), + ], + $this->msg( 'wlnote' )->numParams( $numRows, round( $opts['days'] * 24 ) )->params( + $lang->userDate( $timestamp, $user ), $lang->userTime( $timestamp, $user ) + )->parse() + ) . "<br />\n"; + + $nondefaults = $opts->getChangedValues(); + $cutofflinks = Html::rawElement( + 'span', + [ 'class' => 'cldays cloption' ], + $this->msg( 'wlshowtime' ) . ' ' . $this->cutoffselector( $opts ) + ); + + # Spit out some control panel links + $links = []; + $namesOfDisplayedFilters = []; + foreach ( $this->getLegacyShowHideFilters() as $filterName => $filter ) { + $namesOfDisplayedFilters[] = $filterName; + $links[] = $this->showHideCheck( + $nondefaults, + $filter->getShowHide(), + $filterName, + $opts[ $filterName ], + $filter->isFeatureAvailableOnStructuredUi( $this ) + ); + } + + $hiddenFields = $nondefaults; + $hiddenFields['action'] = 'submit'; + unset( $hiddenFields['namespace'] ); + unset( $hiddenFields['invert'] ); + unset( $hiddenFields['associated'] ); + unset( $hiddenFields['days'] ); + foreach ( $namesOfDisplayedFilters as $filterName ) { + unset( $hiddenFields[$filterName] ); + } + + # Namespace filter and put the whole form together. + $form .= $wlInfo; + $form .= $cutofflinks; + $form .= Html::rawElement( + 'span', + [ 'class' => 'clshowhide' ], + $this->msg( 'watchlist-hide' ) . + $this->msg( 'colon-separator' )->escaped() . + implode( ' ', $links ) + ); + $form .= "\n<br />\n"; + + $namespaceForm = Html::namespaceSelector( + [ + 'selected' => $opts['namespace'], + 'all' => '', + 'label' => $this->msg( 'namespace' )->text() + ], [ + 'name' => 'namespace', + 'id' => 'namespace', + 'class' => 'namespaceselector', + ] + ) . "\n"; + $namespaceForm .= '<span class="mw-input-with-label">' . Xml::checkLabel( + $this->msg( 'invert' )->text(), + 'invert', + 'nsinvert', + $opts['invert'], + [ 'title' => $this->msg( 'tooltip-invert' )->text() ] + ) . "</span>\n"; + $namespaceForm .= '<span class="mw-input-with-label">' . Xml::checkLabel( + $this->msg( 'namespace_association' )->text(), + 'associated', + 'nsassociated', + $opts['associated'], + [ 'title' => $this->msg( 'tooltip-namespace_association' )->text() ] + ) . "</span>\n"; + $form .= Html::rawElement( + 'span', + [ 'class' => 'namespaceForm cloption' ], + $namespaceForm + ); + + $form .= Xml::submitButton( + $this->msg( 'watchlist-submit' )->text(), + [ 'class' => 'cloption-submit' ] + ) . "\n"; + foreach ( $hiddenFields as $key => $value ) { + $form .= Html::hidden( $key, $value ) . "\n"; + } + $form .= Xml::closeElement( 'fieldset' ) . "\n"; + $form .= Xml::closeElement( 'form' ) . "\n"; + + // 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 . $form + ) + ); + + // Add spinner + $this->getOutput()->addHTML( $loadingContainer ); + } else { + $this->getOutput()->addHTML( $form ); + } + + $this->setBottomText( $opts ); + } + + function cutoffselector( $options ) { + $selected = (float)$options['days']; + if ( $selected <= 0 ) { + $selected = $this->maxDays; + } + + $selectedHours = round( $selected * 24 ); + + $hours = array_unique( array_filter( [ + 1, + 2, + 6, + 12, + 24, + 72, + 168, + 24 * (float)$this->getUser()->getOption( 'watchlistdays', 0 ), + 24 * $this->maxDays, + $selectedHours + ] ) ); + asort( $hours ); + + $select = new XmlSelect( 'days', 'days', (float)( $selectedHours / 24 ) ); + + foreach ( $hours as $value ) { + if ( $value < 24 ) { + $name = $this->msg( 'hours' )->numParams( $value )->text(); + } else { + $name = $this->msg( 'days' )->numParams( $value / 24 )->text(); + } + $select->addOption( $name, (float)( $value / 24 ) ); + } + + return $select->getHTML() . "\n<br />\n"; + } + + function setTopText( FormOptions $opts ) { + $nondefaults = $opts->getChangedValues(); + $form = ''; + $user = $this->getUser(); + + $numItems = $this->countItems(); + $showUpdatedMarker = $this->getConfig()->get( 'ShowUpdatedMarker' ); + + // Show watchlist header + $watchlistHeader = ''; + if ( $numItems == 0 ) { + $watchlistHeader = $this->msg( 'nowatchlist' )->parse(); + } else { + $watchlistHeader .= $this->msg( 'watchlist-details' )->numParams( $numItems )->parse() . "\n"; + if ( $this->getConfig()->get( 'EnotifWatchlist' ) + && $user->getOption( 'enotifwatchlistpages' ) + ) { + $watchlistHeader .= $this->msg( 'wlheader-enotif' )->parse() . "\n"; + } + if ( $showUpdatedMarker ) { + $watchlistHeader .= $this->msg( + $this->isStructuredFilterUiEnabled() ? + 'rcfilters-watchlist-showupdated' : + 'wlheader-showupdated' + )->parse() . "\n"; + } + } + $form .= Html::rawElement( + 'div', + [ 'class' => 'watchlistDetails' ], + $watchlistHeader + ); + + if ( $numItems > 0 && $showUpdatedMarker ) { + $form .= Xml::openElement( 'form', [ 'method' => 'post', + 'action' => $this->getPageTitle()->getLocalURL(), + 'id' => 'mw-watchlist-resetbutton' ] ) . "\n" . + Xml::submitButton( $this->msg( 'enotif_reset' )->text(), + [ 'name' => 'mw-watchlist-reset-submit' ] ) . "\n" . + Html::hidden( 'token', $user->getEditToken() ) . "\n" . + Html::hidden( 'reset', 'all' ) . "\n"; + foreach ( $nondefaults as $key => $value ) { + $form .= Html::hidden( $key, $value ) . "\n"; + } + $form .= Xml::closeElement( 'form' ) . "\n"; + } + + $this->getOutput()->addHTML( $form ); + } + + protected function showHideCheck( $options, $message, $name, $value, $inStructuredUi ) { + $options[$name] = 1 - (int)$value; + + $attribs = [ 'class' => 'mw-input-with-label clshowhideoption cloption' ]; + if ( $inStructuredUi ) { + $attribs[ 'data-feature-in-structured-ui' ] = true; + } + + return Html::rawElement( + 'span', + $attribs, + // not using Html::checkLabel because that would escape the contents + Html::check( $name, (int)$value, [ 'id' => $name ] ) . Html::rawElement( + 'label', + $attribs + [ 'for' => $name ], + // <nowiki/> at beginning to avoid messages with "$1 ..." being parsed as pre tags + $this->msg( $message, '<nowiki/>' )->parse() + ) + ); + } + + /** + * Count the number of paired items on a user's watchlist. + * The assumption made here is that when a subject page is watched a talk page is also watched. + * Hence the number of individual items is halved. + * + * @return int + */ + protected function countItems() { + $store = MediaWikiServices::getInstance()->getWatchedItemStore(); + $count = $store->countWatchedItems( $this->getUser() ); + return floor( $count / 2 ); + } +} diff --git a/www/wiki/includes/specials/SpecialWhatlinkshere.php b/www/wiki/includes/specials/SpecialWhatlinkshere.php new file mode 100644 index 00000000..3080fbfe --- /dev/null +++ b/www/wiki/includes/specials/SpecialWhatlinkshere.php @@ -0,0 +1,573 @@ +<?php +/** + * Implements Special:Whatlinkshere + * + * 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 + * @todo Use some variant of Pager or something; the pagination here is lousy. + */ + +use Wikimedia\Rdbms\IDatabase; + +/** + * Implements Special:Whatlinkshere + * + * @ingroup SpecialPage + */ +class SpecialWhatLinksHere extends IncludableSpecialPage { + /** @var FormOptions */ + protected $opts; + + protected $selfTitle; + + /** @var Title */ + protected $target; + + protected $limits = [ 20, 50, 100, 250, 500 ]; + + public function __construct() { + parent::__construct( 'Whatlinkshere' ); + } + + function execute( $par ) { + $out = $this->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' => [ 'INNER 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' => [ 'INNER 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 ( 0 == $level ) { + if ( !$this->including() ) { + $out->addHTML( $this->whatlinkshereForm() ); + + // Show filters only if there are links + if ( $hidelinks || $hidetrans || $hideredirs || $hideimages ) { + $out->addHTML( $this->getFilterPanel() ); + } + $errMsg = is_int( $namespace ) ? 'nolinkshere-ns' : 'nolinkshere'; + $out->addWikiMsg( $errMsg, $this->target->getPrefixedText() ); + $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 ) { + if ( !$this->including() ) { + $out->addHTML( $this->whatlinkshereForm() ); + $out->addHTML( $this->getFilterPanel() ); + $out->addWikiMsg( 'linkshere', $this->target->getPrefixedText() ); + + $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 ) { + if ( !$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 ( 0 != $prevId ) { + $overrides = [ 'from' => $this->opts->getValue( 'back' ) ]; + $prev = $this->makeSelfLink( $prev, array_merge( $changed, $overrides ) ); + } + if ( 0 != $nextId ) { + $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() + ], [ + 'name' => 'namespace', + 'id' => 'namespace', + 'class' => 'namespaceselector', + ] + ); + + $f .= ' ' . + 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/www/wiki/includes/specials/SpecialWithoutinterwiki.php b/www/wiki/includes/specials/SpecialWithoutinterwiki.php new file mode 100644 index 00000000..a1e51563 --- /dev/null +++ b/www/wiki/includes/specials/SpecialWithoutinterwiki.php @@ -0,0 +1,110 @@ +<?php +/** + * Implements Special:Withoutinterwiki + * + * 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 + * @author Rob Church <robchur@gmail.com> + */ + +/** + * Special page lists pages without language links + * + * @ingroup SpecialPage + */ +class WithoutInterwikiPage extends PageQueryPage { + private $prefix = ''; + + function __construct( $name = 'Withoutinterwiki' ) { + parent::__construct( $name ); + } + + function execute( $par ) { + $this->prefix = Title::capitalize( + $this->getRequest()->getVal( 'prefix', $par ), NS_MAIN ); + parent::execute( $par ); + } + + function getPageHeader() { + # Do not show useless input form if special page is cached + if ( $this->isCached() ) { + return ''; + } + + $formDescriptor = [ + 'prefix' => [ + 'label-message' => 'allpagesprefix', + 'name' => 'prefix', + 'id' => 'wiprefix', + 'type' => 'text', + 'size' => 20, + 'default' => $this->prefix + ] + ]; + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm->setWrapperLegend( '' ) + ->setSubmitTextMsg( 'withoutinterwiki-submit' ) + ->setMethod( 'get' ) + ->prepareForm() + ->displayForm( false ); + } + + function sortDescending() { + return false; + } + + function getOrderFields() { + return [ 'page_namespace', 'page_title' ]; + } + + function isExpensive() { + return true; + } + + function isSyndicated() { + return false; + } + + function getQueryInfo() { + $query = [ + 'tables' => [ 'page', 'langlinks' ], + 'fields' => [ + 'namespace' => 'page_namespace', + 'title' => 'page_title', + 'value' => 'page_title' + ], + 'conds' => [ + 'll_title IS NULL', + 'page_namespace' => MWNamespace::getContentNamespaces(), + 'page_is_redirect' => 0 + ], + 'join_conds' => [ 'langlinks' => [ 'LEFT JOIN', 'll_from = page_id' ] ] + ]; + if ( $this->prefix ) { + $dbr = wfGetDB( DB_REPLICA ); + $query['conds'][] = 'page_title ' . $dbr->buildLike( $this->prefix, $dbr->anyString() ); + } + + return $query; + } + + protected function getGroupName() { + return 'maintenance'; + } +} diff --git a/www/wiki/includes/specials/formfields/EditWatchlistCheckboxSeriesField.php b/www/wiki/includes/specials/formfields/EditWatchlistCheckboxSeriesField.php new file mode 100644 index 00000000..cb93bb2c --- /dev/null +++ b/www/wiki/includes/specials/formfields/EditWatchlistCheckboxSeriesField.php @@ -0,0 +1,37 @@ +<?php +/** + * 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 + */ + +class EditWatchlistCheckboxSeriesField extends HTMLMultiSelectField { + /** + * HTMLMultiSelectField throws validation errors if we get input data + * that doesn't match the data set in the form setup. This causes + * problems if something gets removed from the watchlist while the + * form is open (T34126), but we know that invalid items will + * be harmless so we can override it here. + * + * @param string $value The value the field was submitted with + * @param array $alldata The data collected from the form + * @return bool|string Bool true on success, or String error to display. + */ + function validate( $value, $alldata ) { + // Need to call into grandparent to be a good citizen. :) + return HTMLFormField::validate( $value, $alldata ); + } +} diff --git a/www/wiki/includes/specials/formfields/Licenses.php b/www/wiki/includes/specials/formfields/Licenses.php new file mode 100644 index 00000000..931cd240 --- /dev/null +++ b/www/wiki/includes/specials/formfields/Licenses.php @@ -0,0 +1,226 @@ +<?php +/** + * License selector for use on Special:Upload. + * + * 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 + * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com> + * @copyright Copyright © 2005, Ævar Arnfjörð Bjarmason + */ + +/** + * A License class for use on Special:Upload + */ +class Licenses extends HTMLFormField { + /** @var string */ + protected $msg; + + /** @var array */ + protected $lines = []; + + /** @var string */ + protected $html; + + /** @var string|null */ + protected $selected; + /**#@-*/ + + /** + * @param array $params + */ + public function __construct( $params ) { + parent::__construct( $params ); + + $this->msg = static::getMessageFromParams( $params ); + $this->selected = null; + + $this->makeLines(); + } + + /** + * @param array $params + * @return string + */ + protected static function getMessageFromParams( $params ) { + return empty( $params['licenses'] ) + ? wfMessage( 'licenses' )->inContentLanguage()->plain() + : $params['licenses']; + } + + /** + * @param string $line + * @return License + */ + protected function buildLine( $line ) { + return new License( $line ); + } + + /** + * @private + */ + protected function makeLines() { + $levels = []; + $lines = explode( "\n", $this->msg ); + + foreach ( $lines as $line ) { + if ( strpos( $line, '*' ) !== 0 ) { + continue; + } else { + list( $level, $line ) = $this->trimStars( $line ); + + if ( strpos( $line, '|' ) !== false ) { + $obj = $this->buildLine( $line ); + $this->stackItem( $this->lines, $levels, $obj ); + } else { + if ( $level < count( $levels ) ) { + $levels = array_slice( $levels, 0, $level ); + } + if ( $level == count( $levels ) ) { + $levels[$level - 1] = $line; + } elseif ( $level > count( $levels ) ) { + $levels[] = $line; + } + } + } + } + } + + /** + * @param string $str + * @return array + */ + protected function trimStars( $str ) { + $numStars = strspn( $str, '*' ); + return [ $numStars, ltrim( substr( $str, $numStars ), ' ' ) ]; + } + + /** + * @param array &$list + * @param array $path + * @param mixed $item + */ + protected function stackItem( &$list, $path, $item ) { + $position =& $list; + if ( $path ) { + foreach ( $path as $key ) { + $position =& $position[$key]; + } + } + $position[] = $item; + } + + /** + * @param array $tagset + * @param int $depth + * @return string + */ + protected function makeHtml( $tagset, $depth = 0 ) { + $html = ''; + + foreach ( $tagset as $key => $val ) { + if ( is_array( $val ) ) { + $html .= $this->outputOption( + $key, '', + [ + 'disabled' => 'disabled', + 'style' => 'color: GrayText', // for MSIE + ], + $depth + ); + $html .= $this->makeHtml( $val, $depth + 1 ); + } else { + $html .= $this->outputOption( + $val->text, $val->template, + [ 'title' => '{{' . $val->template . '}}' ], + $depth + ); + } + } + + return $html; + } + + /** + * @param string $message + * @param string $value + * @param null|array $attribs + * @param int $depth + * @return string + */ + protected function outputOption( $message, $value, $attribs = null, $depth = 0 ) { + $msgObj = $this->msg( $message ); + $text = $msgObj->exists() ? $msgObj->text() : $message; + $attribs['value'] = $value; + if ( $value === $this->selected ) { + $attribs['selected'] = 'selected'; + } + + $val = str_repeat( /*   */ "\xc2\xa0", $depth * 2 ) . $text; + return str_repeat( "\t", $depth ) . Xml::element( 'option', $attribs, $val ) . "\n"; + } + + /**#@-*/ + + /** + * Accessor for $this->lines + * + * @return array + */ + public function getLines() { + return $this->lines; + } + + /** + * Accessor for $this->lines + * + * @return array + * + * @deprecated since 1.31 Use getLines() instead + */ + public function getLicenses() { + return $this->getLines(); + } + + /** + * {@inheritdoc} + */ + public function getInputHTML( $value ) { + $this->selected = $value; + + // add a default "no license selected" option + $default = $this->buildLine( '|nolicense' ); + array_unshift( $this->lines, $default ); + + $html = $this->makeHtml( $this->getLines() ); + + $attribs = [ + 'name' => $this->mName, + 'id' => $this->mID + ]; + if ( !empty( $this->mParams['disabled'] ) ) { + $attribs['disabled'] = 'disabled'; + } + + $html = Html::rawElement( 'select', $attribs, $html ); + + // remove default "no license selected" from lines again + array_shift( $this->lines ); + + return $html; + } +} diff --git a/www/wiki/includes/specials/formfields/UploadSourceField.php b/www/wiki/includes/specials/formfields/UploadSourceField.php new file mode 100644 index 00000000..251a2866 --- /dev/null +++ b/www/wiki/includes/specials/formfields/UploadSourceField.php @@ -0,0 +1,68 @@ +<?php +/** + * 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 + */ + +/** + * A form field that contains a radio box in the label + */ +class UploadSourceField extends HTMLTextField { + + /** + * @param array $cellAttributes + * @return string + */ + function getLabelHtml( $cellAttributes = [] ) { + $id = $this->mParams['id']; + $label = Html::rawElement( 'label', [ 'for' => $id ], $this->mLabel ); + + if ( !empty( $this->mParams['radio'] ) ) { + if ( isset( $this->mParams['radio-id'] ) ) { + $radioId = $this->mParams['radio-id']; + } else { + // Old way. For the benefit of extensions that do not define + // the 'radio-id' key. + $radioId = 'wpSourceType' . $this->mParams['upload-type']; + } + + $attribs = [ + 'name' => 'wpSourceType', + 'type' => 'radio', + 'id' => $radioId, + 'value' => $this->mParams['upload-type'], + ]; + + if ( !empty( $this->mParams['checked'] ) ) { + $attribs['checked'] = 'checked'; + } + + $label .= Html::element( 'input', $attribs ); + } + + return Html::rawElement( 'td', [ 'class' => 'mw-label' ] + $cellAttributes, $label ); + } + + /** + * @return int + */ + function getSize() { + return isset( $this->mParams['size'] ) + ? $this->mParams['size'] + : 60; + } +} diff --git a/www/wiki/includes/specials/forms/EditWatchlistNormalHTMLForm.php b/www/wiki/includes/specials/forms/EditWatchlistNormalHTMLForm.php new file mode 100644 index 00000000..723093a7 --- /dev/null +++ b/www/wiki/includes/specials/forms/EditWatchlistNormalHTMLForm.php @@ -0,0 +1,36 @@ +<?php +/** + * 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 + */ + +/** + * Extend HTMLForm purely so we can have a more sane way of getting the section headers + */ +class EditWatchlistNormalHTMLForm extends HTMLForm { + public function getLegend( $namespace ) { + $namespace = substr( $namespace, 2 ); + + return $namespace == NS_MAIN + ? $this->msg( 'blanknamespace' )->escaped() + : htmlspecialchars( $this->getContext()->getLanguage()->getFormattedNsText( $namespace ) ); + } + + public function getBody() { + return $this->displaySection( $this->mFieldTree, '', 'editwatchlist-' ); + } +} diff --git a/www/wiki/includes/specials/forms/PreferencesForm.php b/www/wiki/includes/specials/forms/PreferencesForm.php new file mode 100644 index 00000000..d4e5ef4f --- /dev/null +++ b/www/wiki/includes/specials/forms/PreferencesForm.php @@ -0,0 +1,143 @@ +<?php +/** + * 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 + */ + +use MediaWiki\MediaWikiServices; + +/** + * Form to edit user preferences. + */ +class PreferencesForm extends HTMLForm { + // Override default value from HTMLForm + protected $mSubSectionBeforeFields = false; + + private $modifiedUser; + + /** + * @param User $user + */ + public function setModifiedUser( $user ) { + $this->modifiedUser = $user; + } + + /** + * @return User + */ + public function getModifiedUser() { + if ( $this->modifiedUser === null ) { + return $this->getUser(); + } else { + return $this->modifiedUser; + } + } + + /** + * Get extra parameters for the query string when redirecting after + * successful save. + * + * @return array + */ + public function getExtraSuccessRedirectParameters() { + return []; + } + + /** + * @param string $html + * @return string + */ + function wrapForm( $html ) { + $html = Xml::tags( 'div', [ 'id' => 'preferences' ], $html ); + + return parent::wrapForm( $html ); + } + + /** + * @return string + */ + function getButtons() { + $attrs = [ 'id' => 'mw-prefs-restoreprefs' ]; + + if ( !$this->getModifiedUser()->isAllowedAny( 'editmyprivateinfo', 'editmyoptions' ) ) { + return ''; + } + + $html = parent::getButtons(); + + if ( $this->getModifiedUser()->isAllowed( 'editmyoptions' ) ) { + $t = $this->getTitle()->getSubpage( 'reset' ); + + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + $html .= "\n" . $linkRenderer->makeLink( $t, $this->msg( 'restoreprefs' )->text(), + Html::buttonAttributes( $attrs, [ 'mw-ui-quiet' ] ) ); + + $html = Xml::tags( 'div', [ 'class' => 'mw-prefs-buttons' ], $html ); + } + + return $html; + } + + /** + * Separate multi-option preferences into multiple preferences, since we + * have to store them separately + * @param array $data + * @return array + */ + function filterDataForSubmit( $data ) { + foreach ( $this->mFlatFields as $fieldname => $field ) { + if ( $field instanceof HTMLNestedFilterable ) { + $info = $field->mParams; + $prefix = isset( $info['prefix'] ) ? $info['prefix'] : $fieldname; + foreach ( $field->filterDataForSubmit( $data[$fieldname] ) as $key => $value ) { + $data["$prefix$key"] = $value; + } + unset( $data[$fieldname] ); + } + } + + return $data; + } + + /** + * Get the whole body of the form. + * @return string + */ + function getBody() { + return $this->displaySection( $this->mFieldTree, '', 'mw-prefsection-' ); + } + + /** + * Get the "<legend>" for a given section key. Normally this is the + * prefs-$key message but we'll allow extensions to override it. + * @param string $key + * @return string + */ + function getLegend( $key ) { + $legend = parent::getLegend( $key ); + Hooks::run( 'PreferencesGetLegend', [ $this, $key, &$legend ] ); + return $legend; + } + + /** + * Get the keys of each top level preference section. + * @return array of section keys + */ + function getPreferenceSections() { + return array_keys( array_filter( $this->mFieldTree, 'is_array' ) ); + } +} diff --git a/www/wiki/includes/specials/forms/UploadForm.php b/www/wiki/includes/specials/forms/UploadForm.php new file mode 100644 index 00000000..eacdace1 --- /dev/null +++ b/www/wiki/includes/specials/forms/UploadForm.php @@ -0,0 +1,446 @@ +<?php +/** + * 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 + */ + +use MediaWiki\Linker\LinkRenderer; +use MediaWiki\MediaWikiServices; + +/** + * Sub class of HTMLForm that provides the form section of SpecialUpload + */ +class UploadForm extends HTMLForm { + protected $mWatch; + protected $mForReUpload; + protected $mSessionKey; + protected $mHideIgnoreWarning; + protected $mDestWarningAck; + protected $mDestFile; + + protected $mComment; + protected $mTextTop; + protected $mTextAfterSummary; + + protected $mSourceIds; + + protected $mMaxFileSize = []; + + protected $mMaxUploadSize = []; + + public function __construct( array $options = [], IContextSource $context = null, + LinkRenderer $linkRenderer = null + ) { + if ( $context instanceof IContextSource ) { + $this->setContext( $context ); + } + + if ( !$linkRenderer ) { + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + } + + $this->mWatch = !empty( $options['watch'] ); + $this->mForReUpload = !empty( $options['forreupload'] ); + $this->mSessionKey = isset( $options['sessionkey'] ) ? $options['sessionkey'] : ''; + $this->mHideIgnoreWarning = !empty( $options['hideignorewarning'] ); + $this->mDestWarningAck = !empty( $options['destwarningack'] ); + $this->mDestFile = isset( $options['destfile'] ) ? $options['destfile'] : ''; + + $this->mComment = isset( $options['description'] ) ? + $options['description'] : ''; + + $this->mTextTop = isset( $options['texttop'] ) + ? $options['texttop'] : ''; + + $this->mTextAfterSummary = isset( $options['textaftersummary'] ) + ? $options['textaftersummary'] : ''; + + $sourceDescriptor = $this->getSourceSection(); + $descriptor = $sourceDescriptor + + $this->getDescriptionSection() + + $this->getOptionsSection(); + + Hooks::run( 'UploadFormInitDescriptor', [ &$descriptor ] ); + parent::__construct( $descriptor, $context, 'upload' ); + + # Add a link to edit MediaWiki:Licenses + if ( $this->getUser()->isAllowed( 'editinterface' ) ) { + $this->getOutput()->addModuleStyles( 'mediawiki.special.upload.styles' ); + $licensesLink = $linkRenderer->makeKnownLink( + $this->msg( 'licenses' )->inContentLanguage()->getTitle(), + $this->msg( 'licenses-edit' )->text(), + [], + [ 'action' => 'edit' ] + ); + $editLicenses = '<p class="mw-upload-editlicenses">' . $licensesLink . '</p>'; + $this->addFooterText( $editLicenses, 'description' ); + } + + # Set some form properties + $this->setSubmitText( $this->msg( 'uploadbtn' )->text() ); + $this->setSubmitName( 'wpUpload' ); + # Used message keys: 'accesskey-upload', 'tooltip-upload' + $this->setSubmitTooltip( 'upload' ); + $this->setId( 'mw-upload-form' ); + + # Build a list of IDs for javascript insertion + $this->mSourceIds = []; + foreach ( $sourceDescriptor as $field ) { + if ( !empty( $field['id'] ) ) { + $this->mSourceIds[] = $field['id']; + } + } + } + + /** + * Get the descriptor of the fieldset that contains the file source + * selection. The section is 'source' + * + * @return array Descriptor array + */ + protected function getSourceSection() { + if ( $this->mSessionKey ) { + return [ + 'SessionKey' => [ + 'type' => 'hidden', + 'default' => $this->mSessionKey, + ], + 'SourceType' => [ + 'type' => 'hidden', + 'default' => 'Stash', + ], + ]; + } + + $canUploadByUrl = UploadFromUrl::isEnabled() + && ( UploadFromUrl::isAllowed( $this->getUser() ) === true ) + && $this->getConfig()->get( 'CopyUploadsFromSpecialUpload' ); + $radio = $canUploadByUrl; + $selectedSourceType = strtolower( $this->getRequest()->getText( 'wpSourceType', 'File' ) ); + + $descriptor = []; + if ( $this->mTextTop ) { + $descriptor['UploadFormTextTop'] = [ + 'type' => 'info', + 'section' => 'source', + 'default' => $this->mTextTop, + 'raw' => true, + ]; + } + + $this->mMaxUploadSize['file'] = min( + UploadBase::getMaxUploadSize( 'file' ), + UploadBase::getMaxPhpUploadSize() + ); + + $help = $this->msg( 'upload-maxfilesize', + $this->getContext()->getLanguage()->formatSize( $this->mMaxUploadSize['file'] ) + )->parse(); + + // If the user can also upload by URL, there are 2 different file size limits. + // This extra message helps stress which limit corresponds to what. + if ( $canUploadByUrl ) { + $help .= $this->msg( 'word-separator' )->escaped(); + $help .= $this->msg( 'upload_source_file' )->parse(); + } + + $descriptor['UploadFile'] = [ + 'class' => UploadSourceField::class, + 'section' => 'source', + 'type' => 'file', + 'id' => 'wpUploadFile', + 'radio-id' => 'wpSourceTypeFile', + 'label-message' => 'sourcefilename', + 'upload-type' => 'File', + 'radio' => &$radio, + 'help' => $help, + 'checked' => $selectedSourceType == 'file', + ]; + + if ( $canUploadByUrl ) { + $this->mMaxUploadSize['url'] = UploadBase::getMaxUploadSize( 'url' ); + $descriptor['UploadFileURL'] = [ + 'class' => UploadSourceField::class, + 'section' => 'source', + 'id' => 'wpUploadFileURL', + 'radio-id' => 'wpSourceTypeurl', + 'label-message' => 'sourceurl', + 'upload-type' => 'url', + 'radio' => &$radio, + 'help' => $this->msg( 'upload-maxfilesize', + $this->getContext()->getLanguage()->formatSize( $this->mMaxUploadSize['url'] ) + )->parse() . + $this->msg( 'word-separator' )->escaped() . + $this->msg( 'upload_source_url' )->parse(), + 'checked' => $selectedSourceType == 'url', + ]; + } + Hooks::run( 'UploadFormSourceDescriptors', [ &$descriptor, &$radio, $selectedSourceType ] ); + + $descriptor['Extensions'] = [ + 'type' => 'info', + 'section' => 'source', + 'default' => $this->getExtensionsMessage(), + 'raw' => true, + ]; + + return $descriptor; + } + + /** + * Get the messages indicating which extensions are preferred and prohibitted. + * + * @return string HTML string containing the message + */ + protected function getExtensionsMessage() { + # Print a list of allowed file extensions, if so configured. We ignore + # MIME type here, it's incomprehensible to most people and too long. + $config = $this->getConfig(); + + if ( $config->get( 'CheckFileExtensions' ) ) { + $fileExtensions = array_unique( $config->get( 'FileExtensions' ) ); + if ( $config->get( 'StrictFileExtensions' ) ) { + # Everything not permitted is banned + $extensionsList = + '<div id="mw-upload-permitted">' . + $this->msg( 'upload-permitted' ) + ->params( $this->getLanguage()->commaList( $fileExtensions ) ) + ->numParams( count( $fileExtensions ) ) + ->parseAsBlock() . + "</div>\n"; + } else { + # We have to list both preferred and prohibited + $fileBlacklist = array_unique( $config->get( 'FileBlacklist' ) ); + $extensionsList = + '<div id="mw-upload-preferred">' . + $this->msg( 'upload-preferred' ) + ->params( $this->getLanguage()->commaList( $fileExtensions ) ) + ->numParams( count( $fileExtensions ) ) + ->parseAsBlock() . + "</div>\n" . + '<div id="mw-upload-prohibited">' . + $this->msg( 'upload-prohibited' ) + ->params( $this->getLanguage()->commaList( $fileBlacklist ) ) + ->numParams( count( $fileBlacklist ) ) + ->parseAsBlock() . + "</div>\n"; + } + } else { + # Everything is permitted. + $extensionsList = ''; + } + + return $extensionsList; + } + + /** + * Get the descriptor of the fieldset that contains the file description + * input. The section is 'description' + * + * @return array Descriptor array + */ + protected function getDescriptionSection() { + $config = $this->getConfig(); + if ( $this->mSessionKey ) { + $stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $this->getUser() ); + try { + $file = $stash->getFile( $this->mSessionKey ); + } catch ( Exception $e ) { + $file = null; + } + if ( $file ) { + global $wgContLang; + + $mto = $file->transform( [ 'width' => 120 ] ); + if ( $mto ) { + $this->addHeaderText( + '<div class="thumb t' . $wgContLang->alignEnd() . '">' . + Html::element( 'img', [ + 'src' => $mto->getUrl(), + 'class' => 'thumbimage', + ] ) . '</div>', 'description' ); + } + } + } + + $descriptor = [ + 'DestFile' => [ + 'type' => 'text', + 'section' => 'description', + 'id' => 'wpDestFile', + 'label-message' => 'destfilename', + 'size' => 60, + 'default' => $this->mDestFile, + # @todo FIXME: Hack to work around poor handling of the 'default' option in HTMLForm + 'nodata' => strval( $this->mDestFile ) !== '', + ], + 'UploadDescription' => [ + 'type' => 'textarea', + 'section' => 'description', + 'id' => 'wpUploadDescription', + 'label-message' => $this->mForReUpload + ? 'filereuploadsummary' + : 'fileuploadsummary', + 'default' => $this->mComment, + 'cols' => 80, + 'rows' => 8, + ] + ]; + if ( $this->mTextAfterSummary ) { + $descriptor['UploadFormTextAfterSummary'] = [ + 'type' => 'info', + 'section' => 'description', + 'default' => $this->mTextAfterSummary, + 'raw' => true, + ]; + } + + $descriptor += [ + 'EditTools' => [ + 'type' => 'edittools', + 'section' => 'description', + 'message' => 'edittools-upload', + ] + ]; + + if ( $this->mForReUpload ) { + $descriptor['DestFile']['readonly'] = true; + } else { + $descriptor['License'] = [ + 'type' => 'select', + 'class' => Licenses::class, + 'section' => 'description', + 'id' => 'wpLicense', + 'label-message' => 'license', + ]; + } + + if ( $config->get( 'UseCopyrightUpload' ) ) { + $descriptor['UploadCopyStatus'] = [ + 'type' => 'text', + 'section' => 'description', + 'id' => 'wpUploadCopyStatus', + 'label-message' => 'filestatus', + ]; + $descriptor['UploadSource'] = [ + 'type' => 'text', + 'section' => 'description', + 'id' => 'wpUploadSource', + 'label-message' => 'filesource', + ]; + } + + return $descriptor; + } + + /** + * Get the descriptor of the fieldset that contains the upload options, + * such as "watch this file". The section is 'options' + * + * @return array Descriptor array + */ + protected function getOptionsSection() { + $user = $this->getUser(); + if ( $user->isLoggedIn() ) { + $descriptor = [ + 'Watchthis' => [ + 'type' => 'check', + 'id' => 'wpWatchthis', + 'label-message' => 'watchthisupload', + 'section' => 'options', + 'default' => $this->mWatch, + ] + ]; + } + if ( !$this->mHideIgnoreWarning ) { + $descriptor['IgnoreWarning'] = [ + 'type' => 'check', + 'id' => 'wpIgnoreWarning', + 'label-message' => 'ignorewarnings', + 'section' => 'options', + ]; + } + + $descriptor['DestFileWarningAck'] = [ + 'type' => 'hidden', + 'id' => 'wpDestFileWarningAck', + 'default' => $this->mDestWarningAck ? '1' : '', + ]; + + if ( $this->mForReUpload ) { + $descriptor['ForReUpload'] = [ + 'type' => 'hidden', + 'id' => 'wpForReUpload', + 'default' => '1', + ]; + } + + return $descriptor; + } + + /** + * Add the upload JS and show the form. + */ + public function show() { + $this->addUploadJS(); + parent::show(); + } + + /** + * Add upload JS to the OutputPage + */ + protected function addUploadJS() { + $config = $this->getConfig(); + + $useAjaxDestCheck = $config->get( 'UseAjax' ) && $config->get( 'AjaxUploadDestCheck' ); + $useAjaxLicensePreview = $config->get( 'UseAjax' ) && + $config->get( 'AjaxLicensePreview' ) && $config->get( 'EnableAPI' ); + $this->mMaxUploadSize['*'] = UploadBase::getMaxUploadSize(); + + $scriptVars = [ + 'wgAjaxUploadDestCheck' => $useAjaxDestCheck, + 'wgAjaxLicensePreview' => $useAjaxLicensePreview, + 'wgUploadAutoFill' => !$this->mForReUpload && + // If we received mDestFile from the request, don't autofill + // the wpDestFile textbox + $this->mDestFile === '', + 'wgUploadSourceIds' => $this->mSourceIds, + 'wgCheckFileExtensions' => $config->get( 'CheckFileExtensions' ), + 'wgStrictFileExtensions' => $config->get( 'StrictFileExtensions' ), + 'wgFileExtensions' => array_values( array_unique( $config->get( 'FileExtensions' ) ) ), + 'wgCapitalizeUploads' => MWNamespace::isCapitalized( NS_FILE ), + 'wgMaxUploadSize' => $this->mMaxUploadSize, + 'wgFileCanRotate' => SpecialUpload::rotationEnabled(), + ]; + + $out = $this->getOutput(); + $out->addJsConfigVars( $scriptVars ); + + $out->addModules( [ + 'mediawiki.special.upload', // Extras for thumbnail and license preview. + ] ); + } + + /** + * Empty function; submission is handled elsewhere. + * + * @return bool False + */ + function trySubmit() { + return false; + } +} diff --git a/www/wiki/includes/specials/helpers/ImportReporter.php b/www/wiki/includes/specials/helpers/ImportReporter.php new file mode 100644 index 00000000..63addb87 --- /dev/null +++ b/www/wiki/includes/specials/helpers/ImportReporter.php @@ -0,0 +1,190 @@ +<?php +/** + * 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 + */ + +use MediaWiki\MediaWikiServices; + +/** + * Reporting callback + * @ingroup SpecialPage + */ +class ImportReporter extends ContextSource { + private $reason = false; + private $logTags = []; + private $mOriginalLogCallback = null; + private $mOriginalPageOutCallback = null; + private $mLogItemCount = 0; + + /** + * @param WikiImporter $importer + * @param bool $upload + * @param string $interwiki + * @param string|bool $reason + */ + function __construct( $importer, $upload, $interwiki, $reason = false ) { + $this->mOriginalPageOutCallback = + $importer->setPageOutCallback( [ $this, 'reportPage' ] ); + $this->mOriginalLogCallback = + $importer->setLogItemCallback( [ $this, 'reportLogItem' ] ); + $importer->setNoticeCallback( [ $this, 'reportNotice' ] ); + $this->mPageCount = 0; + $this->mIsUpload = $upload; + $this->mInterwiki = $interwiki; + $this->reason = $reason; + } + + /** + * Sets change tags to apply to the import log entry and null revision. + * + * @param array $tags + * @since 1.29 + */ + public function setChangeTags( array $tags ) { + $this->logTags = $tags; + } + + function open() { + $this->getOutput()->addHTML( "<ul>\n" ); + } + + function reportNotice( $msg, array $params ) { + $this->getOutput()->addHTML( + Html::element( 'li', [], $this->msg( $msg, $params )->text() ) + ); + } + + function reportLogItem( /* ... */ ) { + $this->mLogItemCount++; + if ( is_callable( $this->mOriginalLogCallback ) ) { + call_user_func_array( $this->mOriginalLogCallback, func_get_args() ); + } + } + + /** + * @param Title $title + * @param ForeignTitle $foreignTitle + * @param int $revisionCount + * @param int $successCount + * @param array $pageInfo + * @return void + */ + public function reportPage( $title, $foreignTitle, $revisionCount, + $successCount, $pageInfo ) { + $args = func_get_args(); + call_user_func_array( $this->mOriginalPageOutCallback, $args ); + + if ( $title === null ) { + # Invalid or non-importable title; a notice is already displayed + return; + } + + $this->mPageCount++; + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + if ( $successCount > 0 ) { + // <bdi> prevents jumbling of the versions count + // in RTL wikis in case the page title is LTR + $this->getOutput()->addHTML( + "<li>" . $linkRenderer->makeLink( $title ) . " " . + "<bdi>" . + $this->msg( 'import-revision-count' )->numParams( $successCount )->escaped() . + "</bdi>" . + "</li>\n" + ); + + $logParams = [ '4:number:count' => $successCount ]; + if ( $this->mIsUpload ) { + $detail = $this->msg( 'import-logentry-upload-detail' )->numParams( + $successCount )->inContentLanguage()->text(); + $action = 'upload'; + } else { + $pageTitle = $foreignTitle->getFullText(); + $fullInterwikiPrefix = $this->mInterwiki; + Hooks::run( 'ImportLogInterwikiLink', [ &$fullInterwikiPrefix, &$pageTitle ] ); + + $interwikiTitleStr = $fullInterwikiPrefix . ':' . $pageTitle; + $interwiki = '[[:' . $interwikiTitleStr . ']]'; + $detail = $this->msg( 'import-logentry-interwiki-detail' )->numParams( + $successCount )->params( $interwiki )->inContentLanguage()->text(); + $action = 'interwiki'; + $logParams['5:title-link:interwiki'] = $interwikiTitleStr; + } + if ( $this->reason ) { + $detail .= $this->msg( 'colon-separator' )->inContentLanguage()->text() + . $this->reason; + } + + $comment = $detail; // quick + $dbw = wfGetDB( DB_MASTER ); + $latest = $title->getLatestRevID(); + $nullRevision = Revision::newNullRevision( + $dbw, + $title->getArticleID(), + $comment, + true, + $this->getUser() + ); + + $nullRevId = null; + if ( !is_null( $nullRevision ) ) { + $nullRevId = $nullRevision->insertOn( $dbw ); + $page = WikiPage::factory( $title ); + # Update page record + $page->updateRevisionOn( $dbw, $nullRevision ); + Hooks::run( + 'NewRevisionFromEditComplete', + [ $page, $nullRevision, $latest, $this->getUser() ] + ); + } + + // Create the import log entry + $logEntry = new ManualLogEntry( 'import', $action ); + $logEntry->setTarget( $title ); + $logEntry->setComment( $this->reason ); + $logEntry->setPerformer( $this->getUser() ); + $logEntry->setParameters( $logParams ); + $logid = $logEntry->insert(); + if ( count( $this->logTags ) ) { + $logEntry->setTags( $this->logTags ); + } + // Make sure the null revision will be tagged as well + $logEntry->setAssociatedRevId( $nullRevId ); + + $logEntry->publish( $logid ); + + } else { + $this->getOutput()->addHTML( "<li>" . $linkRenderer->makeKnownLink( $title ) . " " . + $this->msg( 'import-nonewrevisions' )->escaped() . "</li>\n" ); + } + } + + function close() { + $out = $this->getOutput(); + if ( $this->mLogItemCount > 0 ) { + $msg = $this->msg( 'imported-log-entries' )->numParams( $this->mLogItemCount )->parse(); + $out->addHTML( Xml::tags( 'li', null, $msg ) ); + } elseif ( $this->mPageCount == 0 && $this->mLogItemCount == 0 ) { + $out->addHTML( "</ul>\n" ); + + return Status::newFatal( 'importnopages' ); + } + $out->addHTML( "</ul>\n" ); + + return Status::newGood( $this->mPageCount ); + } +} diff --git a/www/wiki/includes/specials/helpers/License.php b/www/wiki/includes/specials/helpers/License.php new file mode 100644 index 00000000..940f69c7 --- /dev/null +++ b/www/wiki/includes/specials/helpers/License.php @@ -0,0 +1,61 @@ +<?php +/** + * License selector for use on Special:Upload. + * + * 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 + * @author Ævar Arnfjörð Bjarmason <avarab@gmail.com> + * @copyright Copyright © 2005, Ævar Arnfjörð Bjarmason + * @license http://www.gnu.org/copyleft/gpl.html GNU General Public License 2.0 or later + */ + +/** + * A License class for use on Special:Upload (represents a single type of license). + */ +class License { + /** @var string */ + public $template; + + /** @var string */ + public $text; + + /** + * @param string $str + */ + public function __construct( $str ) { + $str = $this->parse( $str ); + list( $this->template, $this->text ) = $this->split( $str ); + } + + /** + * @param string $str + * @return string + */ + protected function parse( $str ) { + return $str; + } + + /** + * @param string $str + * @return string[] Array with [template, text] + */ + protected function split( $str ) { + list( $text, $template ) = explode( '|', strrev( $str ), 2 ); + return [ strrev( $template ), strrev( $text ) ]; + } +} diff --git a/www/wiki/includes/specials/helpers/LoginHelper.php b/www/wiki/includes/specials/helpers/LoginHelper.php new file mode 100644 index 00000000..a35a420e --- /dev/null +++ b/www/wiki/includes/specials/helpers/LoginHelper.php @@ -0,0 +1,98 @@ +<?php + +/** + * Helper functions for the login form that need to be shared with other special pages + * (such as CentralAuth's SpecialCentralLogin). + * @since 1.27 + */ +class LoginHelper extends ContextSource { + /** + * Valid error and warning messages + * + * Special:Userlogin can show an error or warning message on the form when + * coming from another page. This is done via the ?error= or ?warning= GET + * parameters. + * + * This array is the list of valid message keys. Further keys can be added by the + * LoginFormValidErrorMessages hook. All other values will be ignored. + * + * @var string[] + */ + public static $validErrorMessages = [ + 'exception-nologin-text', + 'watchlistanontext', + 'changeemail-no-info', + 'resetpass-no-info', + 'confirmemail_needlogin', + 'prefsnologintext2', + ]; + + /** + * Returns an array of all valid error messages. + * + * @return array + * @see LoginHelper::$validErrorMessages + */ + public static function getValidErrorMessages() { + static $messages = null; + if ( !$messages ) { + $messages = self::$validErrorMessages; + Hooks::run( 'LoginFormValidErrorMessages', [ &$messages ] ); + } + + return $messages; + } + + public function __construct( IContextSource $context ) { + $this->setContext( $context ); + } + + /** + * Show a return link or redirect to it. + * Extensions can change where the link should point or inject content into the page + * (which will change it from redirect to link mode). + * + * @param string $type One of the following: + * - error: display a return to link ignoring $wgRedirectOnLogin + * - success: display a return to link using $wgRedirectOnLogin if needed + * - successredirect: send an HTTP redirect using $wgRedirectOnLogin if needed + * @param string $returnTo + * @param array|string $returnToQuery + * @param bool $stickHTTPS Keep redirect link on HTTPS + */ + public function showReturnToPage( + $type, $returnTo = '', $returnToQuery = '', $stickHTTPS = false + ) { + global $wgRedirectOnLogin, $wgSecureLogin; + + if ( $type !== 'error' && $wgRedirectOnLogin !== null ) { + $returnTo = $wgRedirectOnLogin; + $returnToQuery = []; + } elseif ( is_string( $returnToQuery ) ) { + $returnToQuery = wfCgiToArray( $returnToQuery ); + } + + // Allow modification of redirect behavior + Hooks::run( 'PostLoginRedirect', [ &$returnTo, &$returnToQuery, &$type ] ); + + $returnToTitle = Title::newFromText( $returnTo ) ?: Title::newMainPage(); + + if ( $wgSecureLogin && !$stickHTTPS ) { + $options = [ 'http' ]; + $proto = PROTO_HTTP; + } elseif ( $wgSecureLogin ) { + $options = [ 'https' ]; + $proto = PROTO_HTTPS; + } else { + $options = []; + $proto = PROTO_RELATIVE; + } + + if ( $type === 'successredirect' ) { + $redirectUrl = $returnToTitle->getFullUrlForRedirect( $returnToQuery, $proto ); + $this->getOutput()->redirect( $redirectUrl ); + } else { + $this->getOutput()->addReturnTo( $returnToTitle, $returnToQuery, null, $options ); + } + } +} diff --git a/www/wiki/includes/specials/pagers/ActiveUsersPager.php b/www/wiki/includes/specials/pagers/ActiveUsersPager.php new file mode 100644 index 00000000..83fb8493 --- /dev/null +++ b/www/wiki/includes/specials/pagers/ActiveUsersPager.php @@ -0,0 +1,195 @@ +<?php +/** + * 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 Pager + */ + +/** + * This class is used to get a list of active users. The ones with specials + * rights (sysop, bureaucrat, developer) will have them displayed + * next to their names. + * + * @ingroup Pager + */ +class ActiveUsersPager extends UsersPager { + + /** + * @var FormOptions + */ + protected $opts; + + /** + * @var string[] + */ + protected $groups; + + /** + * @var array + */ + private $blockStatusByUid; + + /** + * @param IContextSource $context + * @param FormOptions $opts + */ + function __construct( IContextSource $context = null, FormOptions $opts ) { + parent::__construct( $context ); + + $this->RCMaxAge = $this->getConfig()->get( 'ActiveUserDays' ); + $this->requestedUser = ''; + + $un = $opts->getValue( 'username' ); + if ( $un != '' ) { + $username = Title::makeTitleSafe( NS_USER, $un ); + if ( !is_null( $username ) ) { + $this->requestedUser = $username->getText(); + } + } + + $this->groups = $opts->getValue( 'groups' ); + $this->excludegroups = $opts->getValue( 'excludegroups' ); + // Backwards-compatibility with old URLs + if ( $opts->getValue( 'hidebots' ) ) { + $this->excludegroups[] = 'bot'; + } + if ( $opts->getValue( 'hidesysops' ) ) { + $this->excludegroups[] = 'sysop'; + } + } + + function getIndexField() { + return 'qcc_title'; + } + + function getQueryInfo() { + $dbr = $this->getDatabase(); + + $rcQuery = ActorMigration::newMigration()->getJoin( 'rc_user' ); + + $activeUserSeconds = $this->getConfig()->get( 'ActiveUserDays' ) * 86400; + $timestamp = $dbr->timestamp( wfTimestamp( TS_UNIX ) - $activeUserSeconds ); + $tables = [ 'querycachetwo', 'user', 'rc' => [ 'recentchanges' ] + $rcQuery['tables'] ]; + $jconds = [ + 'user' => [ 'JOIN', 'user_name = qcc_title' ], + 'rc' => [ 'JOIN', $rcQuery['fields']['rc_user_text'] . ' = qcc_title' ], + ] + $rcQuery['joins']; + $conds = [ + 'qcc_type' => 'activeusers', + 'qcc_namespace' => NS_USER, + 'rc_type != ' . $dbr->addQuotes( RC_EXTERNAL ), // Don't count wikidata. + 'rc_type != ' . $dbr->addQuotes( RC_CATEGORIZE ), // Don't count categorization changes. + 'rc_log_type IS NULL OR rc_log_type != ' . $dbr->addQuotes( 'newusers' ), + 'rc_timestamp >= ' . $dbr->addQuotes( $timestamp ), + ]; + if ( $this->requestedUser != '' ) { + $conds[] = 'qcc_title >= ' . $dbr->addQuotes( $this->requestedUser ); + } + if ( $this->groups !== [] ) { + $tables[] = 'user_groups'; + $jconds['user_groups'] = [ 'JOIN', [ 'ug_user = user_id' ] ]; + $conds['ug_group'] = $this->groups; + $conds[] = 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() ); + } + if ( $this->excludegroups !== [] ) { + foreach ( $this->excludegroups as $group ) { + $conds[] = 'NOT EXISTS (' . $dbr->selectSQLText( + 'user_groups', '1', [ + 'ug_user = user_id', + 'ug_group' => $group, + 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() ) + ] + ) . ')'; + } + } + if ( !$this->getUser()->isAllowed( 'hideuser' ) ) { + $conds[] = 'NOT EXISTS (' . $dbr->selectSQLText( + 'ipblocks', '1', [ 'ipb_user=user_id', 'ipb_deleted' => 1 ] + ) . ')'; + } + + return [ + 'tables' => $tables, + 'fields' => [ + 'qcc_title', + 'user_name' => 'qcc_title', + 'user_id' => 'MAX(user_id)', + 'recentedits' => 'COUNT(*)' + ], + 'options' => [ 'GROUP BY' => [ 'qcc_title' ] ], + 'conds' => $conds, + 'join_conds' => $jconds, + ]; + } + + function doBatchLookups() { + parent::doBatchLookups(); + + $uids = []; + foreach ( $this->mResult as $row ) { + $uids[] = $row->user_id; + } + // Fetch the block status of the user for showing "(blocked)" text and for + // striking out names of suppressed users when privileged user views the list. + // Although the first query already hits the block table for un-privileged, this + // is done in two queries to avoid huge quicksorts and to make COUNT(*) correct. + $dbr = $this->getDatabase(); + $res = $dbr->select( 'ipblocks', + [ 'ipb_user', 'MAX(ipb_deleted) AS block_status' ], + [ 'ipb_user' => $uids ], + __METHOD__, + [ 'GROUP BY' => [ 'ipb_user' ] ] + ); + $this->blockStatusByUid = []; + foreach ( $res as $row ) { + $this->blockStatusByUid[$row->ipb_user] = $row->block_status; // 0 or 1 + } + $this->mResult->seek( 0 ); + } + + function formatRow( $row ) { + $userName = $row->user_name; + + $ulinks = Linker::userLink( $row->user_id, $userName ); + $ulinks .= Linker::userToolLinks( $row->user_id, $userName ); + + $lang = $this->getLanguage(); + + $list = []; + $user = User::newFromId( $row->user_id ); + + $ugms = self::getGroupMemberships( intval( $row->user_id ), $this->userGroupCache ); + foreach ( $ugms as $ugm ) { + $list[] = $this->buildGroupLink( $ugm, $userName ); + } + + $groups = $lang->commaList( $list ); + + $item = $lang->specialList( $ulinks, $groups ); + + $isBlocked = isset( $this->blockStatusByUid[$row->user_id] ); + if ( $isBlocked && $this->blockStatusByUid[$row->user_id] == 1 ) { + $item = "<span class=\"deleted\">$item</span>"; + } + $count = $this->msg( 'activeusers-count' )->numParams( $row->recentedits ) + ->params( $userName )->numParams( $this->RCMaxAge )->escaped(); + $blocked = $isBlocked ? ' ' . $this->msg( 'listusers-blocked', $userName )->escaped() : ''; + + return Html::rawElement( 'li', [], "{$item} [{$count}]{$blocked}" ); + } + +} diff --git a/www/wiki/includes/specials/pagers/AllMessagesTablePager.php b/www/wiki/includes/specials/pagers/AllMessagesTablePager.php new file mode 100644 index 00000000..e6a0f0be --- /dev/null +++ b/www/wiki/includes/specials/pagers/AllMessagesTablePager.php @@ -0,0 +1,424 @@ +<?php +/** + * 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 Pager + */ + +use Wikimedia\Rdbms\FakeResultWrapper; + +/** + * Use TablePager for prettified output. We have to pretend that we're + * getting data from a table when in fact not all of it comes from the database. + * + * @ingroup Pager + */ +use MediaWiki\MediaWikiServices; + +class AllMessagesTablePager extends TablePager { + + protected $filter, $prefix, $langcode, $displayPrefix; + + public $mLimitsShown; + + /** + * @var Language + */ + public $lang; + + /** + * @var null|bool + */ + public $custom; + + function __construct( $page, $conds, $langObj = null ) { + parent::__construct( $page->getContext() ); + $this->mIndexField = 'am_title'; + $this->mPage = $page; + $this->mConds = $conds; + // FIXME: Why does this need to be set to DIR_DESCENDING to produce ascending ordering? + $this->mDefaultDirection = IndexPager::DIR_DESCENDING; + $this->mLimitsShown = [ 20, 50, 100, 250, 500, 5000 ]; + + global $wgContLang; + + $this->talk = $this->msg( 'talkpagelinktext' )->escaped(); + + $this->lang = ( $langObj ? $langObj : $wgContLang ); + $this->langcode = $this->lang->getCode(); + $this->foreign = !$this->lang->equals( $wgContLang ); + + $request = $this->getRequest(); + + $this->filter = $request->getVal( 'filter', 'all' ); + if ( $this->filter === 'all' ) { + $this->custom = null; // So won't match in either case + } else { + $this->custom = ( $this->filter === 'unmodified' ); + } + + $prefix = $this->getLanguage()->ucfirst( $request->getVal( 'prefix', '' ) ); + $prefix = $prefix !== '' ? + Title::makeTitleSafe( NS_MEDIAWIKI, $request->getVal( 'prefix', null ) ) : + null; + + if ( $prefix !== null ) { + $this->displayPrefix = $prefix->getDBkey(); + $this->prefix = '/^' . preg_quote( $this->displayPrefix, '/' ) . '/i'; + } else { + $this->displayPrefix = false; + $this->prefix = false; + } + + // The suffix that may be needed for message names if we're in a + // different language (eg [[MediaWiki:Foo/fr]]: $suffix = '/fr' + if ( $this->foreign ) { + $this->suffix = '/' . $this->langcode; + } else { + $this->suffix = ''; + } + } + + function buildForm() { + $attrs = [ 'id' => 'mw-allmessages-form-lang', 'name' => 'lang' ]; + $msg = wfMessage( 'allmessages-language' ); + $langSelect = Xml::languageSelector( $this->langcode, false, null, $attrs, $msg ); + + $out = Xml::openElement( 'form', [ + 'method' => 'get', + 'action' => $this->getConfig()->get( 'Script' ), + 'id' => 'mw-allmessages-form' + ] ) . + Xml::fieldset( $this->msg( 'allmessages-filter-legend' )->text() ) . + Html::hidden( 'title', $this->getTitle()->getPrefixedText() ) . + Xml::openElement( 'table', [ 'class' => 'mw-allmessages-table' ] ) . "\n" . + '<tr> + <td class="mw-label">' . + Xml::label( $this->msg( 'allmessages-prefix' )->text(), 'mw-allmessages-form-prefix' ) . + "</td>\n + <td class=\"mw-input\">" . + Xml::input( + 'prefix', + 20, + str_replace( '_', ' ', $this->displayPrefix ), + [ 'id' => 'mw-allmessages-form-prefix' ] + ) . + "</td>\n + </tr> + <tr>\n + <td class='mw-label'>" . + $this->msg( 'allmessages-filter' )->escaped() . + "</td>\n + <td class='mw-input'>" . + Xml::radioLabel( $this->msg( 'allmessages-filter-unmodified' )->text(), + 'filter', + 'unmodified', + 'mw-allmessages-form-filter-unmodified', + ( $this->filter === 'unmodified' ) + ) . + Xml::radioLabel( $this->msg( 'allmessages-filter-all' )->text(), + 'filter', + 'all', + 'mw-allmessages-form-filter-all', + ( $this->filter === 'all' ) + ) . + Xml::radioLabel( $this->msg( 'allmessages-filter-modified' )->text(), + 'filter', + 'modified', + 'mw-allmessages-form-filter-modified', + ( $this->filter === 'modified' ) + ) . + "</td>\n + </tr> + <tr>\n + <td class=\"mw-label\">" . $langSelect[0] . "</td>\n + <td class=\"mw-input\">" . $langSelect[1] . "</td>\n + </tr>" . + + '<tr> + <td class="mw-label">' . + Xml::label( $this->msg( 'table_pager_limit_label' )->text(), 'mw-table_pager_limit_label' ) . + '</td> + <td class="mw-input">' . + $this->getLimitSelect( [ 'id' => 'mw-table_pager_limit_label' ] ) . + '</td> + <tr> + <td></td> + <td>' . + Xml::submitButton( $this->msg( 'allmessages-filter-submit' )->text() ) . + "</td>\n + </tr>" . + + Xml::closeElement( 'table' ) . + $this->getHiddenFields( [ 'title', 'prefix', 'filter', 'lang', 'limit' ] ) . + Xml::closeElement( 'fieldset' ) . + Xml::closeElement( 'form' ); + + return $out; + } + + function getAllMessages( $descending ) { + $messageNames = Language::getLocalisationCache()->getSubitemList( 'en', 'messages' ); + + // Normalise message names so they look like page titles and sort correctly - T86139 + $messageNames = array_map( [ $this->lang, 'ucfirst' ], $messageNames ); + + if ( $descending ) { + rsort( $messageNames ); + } else { + asort( $messageNames ); + } + + return $messageNames; + } + + /** + * Determine which of the MediaWiki and MediaWiki_talk namespace pages exist. + * Returns [ 'pages' => ..., 'talks' => ... ], where the subarrays have + * an entry for each existing page, with the key being the message name and + * value arbitrary. + * + * @param array $messageNames + * @param string $langcode What language code + * @param bool $foreign Whether the $langcode is not the content language + * @return array A 'pages' and 'talks' array with the keys of existing pages + */ + public static function getCustomisedStatuses( $messageNames, $langcode = 'en', $foreign = false ) { + // FIXME: This function should be moved to Language:: or something. + + $dbr = wfGetDB( DB_REPLICA ); + $res = $dbr->select( 'page', + [ 'page_namespace', 'page_title' ], + [ 'page_namespace' => [ NS_MEDIAWIKI, NS_MEDIAWIKI_TALK ] ], + __METHOD__, + [ 'USE INDEX' => 'name_title' ] + ); + $xNames = array_flip( $messageNames ); + + $pageFlags = $talkFlags = []; + + foreach ( $res as $s ) { + $exists = false; + + if ( $foreign ) { + $titleParts = explode( '/', $s->page_title ); + if ( count( $titleParts ) === 2 && + $langcode === $titleParts[1] && + isset( $xNames[$titleParts[0]] ) + ) { + $exists = $titleParts[0]; + } + } elseif ( isset( $xNames[$s->page_title] ) ) { + $exists = $s->page_title; + } + + $title = Title::newFromRow( $s ); + if ( $exists && $title->inNamespace( NS_MEDIAWIKI ) ) { + $pageFlags[$exists] = true; + } elseif ( $exists && $title->inNamespace( NS_MEDIAWIKI_TALK ) ) { + $talkFlags[$exists] = true; + } + } + + return [ 'pages' => $pageFlags, 'talks' => $talkFlags ]; + } + + /** + * This function normally does a database query to get the results; we need + * to make a pretend result using a FakeResultWrapper. + * @param string $offset + * @param int $limit + * @param bool $descending + * @return FakeResultWrapper + */ + function reallyDoQuery( $offset, $limit, $descending ) { + $result = new FakeResultWrapper( [] ); + + $messageNames = $this->getAllMessages( $descending ); + $statuses = self::getCustomisedStatuses( $messageNames, $this->langcode, $this->foreign ); + + $count = 0; + foreach ( $messageNames as $key ) { + $customised = isset( $statuses['pages'][$key] ); + if ( $customised !== $this->custom && + ( $descending && ( $key < $offset || !$offset ) || !$descending && $key > $offset ) && + ( ( $this->prefix && preg_match( $this->prefix, $key ) ) || $this->prefix === false ) + ) { + $actual = wfMessage( $key )->inLanguage( $this->langcode )->plain(); + $default = wfMessage( $key )->inLanguage( $this->langcode )->useDatabase( false )->plain(); + $result->result[] = [ + 'am_title' => $key, + 'am_actual' => $actual, + 'am_default' => $default, + 'am_customised' => $customised, + 'am_talk_exists' => isset( $statuses['talks'][$key] ) + ]; + $count++; + } + + if ( $count === $limit ) { + break; + } + } + + return $result; + } + + function getStartBody() { + $tableClass = $this->getTableClass(); + return Xml::openElement( 'table', [ + 'class' => "mw-datatable $tableClass", + 'id' => 'mw-allmessagestable' + ] ) . + "\n" . + "<thead><tr> + <th rowspan=\"2\">" . + $this->msg( 'allmessagesname' )->escaped() . " + </th> + <th>" . + $this->msg( 'allmessagesdefault' )->escaped() . + "</th> + </tr>\n + <tr> + <th>" . + $this->msg( 'allmessagescurrent' )->escaped() . + "</th> + </tr></thead><tbody>\n"; + } + + function formatValue( $field, $value ) { + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + switch ( $field ) { + case 'am_title' : + $title = Title::makeTitle( NS_MEDIAWIKI, $value . $this->suffix ); + $talk = Title::makeTitle( NS_MEDIAWIKI_TALK, $value . $this->suffix ); + $translation = Linker::makeExternalLink( + 'https://translatewiki.net/w/i.php?' . wfArrayToCgi( [ + 'title' => 'Special:SearchTranslations', + 'group' => 'mediawiki', + 'grouppath' => 'mediawiki', + 'language' => $this->getLanguage()->getCode(), + 'query' => $value . ' ' . $this->msg( $value )->plain() + ] ), + $this->msg( 'allmessages-filter-translate' )->text() + ); + + if ( $this->mCurrentRow->am_customised ) { + $title = $linkRenderer->makeKnownLink( $title, $this->getLanguage()->lcfirst( $value ) ); + } else { + $title = $linkRenderer->makeBrokenLink( + $title, + $this->getLanguage()->lcfirst( $value ) + ); + } + if ( $this->mCurrentRow->am_talk_exists ) { + $talk = $linkRenderer->makeKnownLink( $talk, $this->talk ); + } else { + $talk = $linkRenderer->makeBrokenLink( + $talk, + $this->talk + ); + } + + return $title . ' ' . + $this->msg( 'parentheses' )->rawParams( $talk )->escaped() . + ' ' . + $this->msg( 'parentheses' )->rawParams( $translation )->escaped(); + + case 'am_default' : + case 'am_actual' : + return Sanitizer::escapeHtmlAllowEntities( $value ); + } + + return ''; + } + + function formatRow( $row ) { + // Do all the normal stuff + $s = parent::formatRow( $row ); + + // But if there's a customised message, add that too. + if ( $row->am_customised ) { + $s .= Xml::openElement( 'tr', $this->getRowAttrs( $row, true ) ); + $formatted = strval( $this->formatValue( 'am_actual', $row->am_actual ) ); + + if ( $formatted === '' ) { + $formatted = ' '; + } + + $s .= Xml::tags( 'td', $this->getCellAttrs( 'am_actual', $row->am_actual ), $formatted ) + . "</tr>\n"; + } + + return $s; + } + + function getRowAttrs( $row, $isSecond = false ) { + $arr = []; + + if ( $row->am_customised ) { + $arr['class'] = 'allmessages-customised'; + } + + if ( !$isSecond ) { + $arr['id'] = Sanitizer::escapeIdForAttribute( + 'msg_' . $this->getLanguage()->lcfirst( $row->am_title ) + ); + } + + return $arr; + } + + function getCellAttrs( $field, $value ) { + if ( $this->mCurrentRow->am_customised && $field === 'am_title' ) { + return [ 'rowspan' => '2', 'class' => $field ]; + } elseif ( $field === 'am_title' ) { + return [ 'class' => $field ]; + } else { + return [ + 'lang' => $this->lang->getHtmlCode(), + 'dir' => $this->lang->getDir(), + 'class' => $field + ]; + } + } + + // This is not actually used, as getStartBody is overridden above + function getFieldNames() { + return [ + 'am_title' => $this->msg( 'allmessagesname' )->text(), + 'am_default' => $this->msg( 'allmessagesdefault' )->text() + ]; + } + + function getTitle() { + return SpecialPage::getTitleFor( 'Allmessages', false ); + } + + function isFieldSortable( $x ) { + return false; + } + + function getDefaultSort() { + return ''; + } + + function getQueryInfo() { + return ''; + } + +} diff --git a/www/wiki/includes/specials/pagers/BlockListPager.php b/www/wiki/includes/specials/pagers/BlockListPager.php new file mode 100644 index 00000000..5789c283 --- /dev/null +++ b/www/wiki/includes/specials/pagers/BlockListPager.php @@ -0,0 +1,312 @@ +<?php +/** + * 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 Pager + */ + +/** + * @ingroup Pager + */ +use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\IResultWrapper; + +class BlockListPager extends TablePager { + + protected $conds; + protected $page; + + /** + * @param SpecialPage $page + * @param array $conds + */ + function __construct( $page, $conds ) { + $this->page = $page; + $this->conds = $conds; + $this->mDefaultDirection = IndexPager::DIR_DESCENDING; + parent::__construct( $page->getContext() ); + } + + function getFieldNames() { + static $headers = null; + + if ( $headers === null ) { + $headers = [ + 'ipb_timestamp' => 'blocklist-timestamp', + 'ipb_target' => 'blocklist-target', + 'ipb_expiry' => 'blocklist-expiry', + 'ipb_by' => 'blocklist-by', + 'ipb_params' => 'blocklist-params', + 'ipb_reason' => 'blocklist-reason', + ]; + foreach ( $headers as $key => $val ) { + $headers[$key] = $this->msg( $val )->text(); + } + } + + return $headers; + } + + function formatValue( $name, $value ) { + static $msg = null; + if ( $msg === null ) { + $keys = [ + 'anononlyblock', + 'createaccountblock', + 'noautoblockblock', + 'emailblock', + 'blocklist-nousertalk', + 'unblocklink', + 'change-blocklink', + ]; + + foreach ( $keys as $key ) { + $msg[$key] = $this->msg( $key )->text(); + } + } + + /** @var object $row */ + $row = $this->mCurrentRow; + + $language = $this->getLanguage(); + + $formatted = ''; + + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + + switch ( $name ) { + case 'ipb_timestamp': + $formatted = htmlspecialchars( $language->userTimeAndDate( $value, $this->getUser() ) ); + break; + + case 'ipb_target': + if ( $row->ipb_auto ) { + $formatted = $this->msg( 'autoblockid', $row->ipb_id )->parse(); + } else { + list( $target, $type ) = Block::parseTarget( $row->ipb_address ); + switch ( $type ) { + case Block::TYPE_USER: + case Block::TYPE_IP: + $formatted = Linker::userLink( $target->getId(), $target ); + $formatted .= Linker::userToolLinks( + $target->getId(), + $target, + false, + Linker::TOOL_LINKS_NOBLOCK + ); + break; + case Block::TYPE_RANGE: + $formatted = htmlspecialchars( $target ); + } + } + break; + + case 'ipb_expiry': + $formatted = htmlspecialchars( $language->formatExpiry( + $value, + /* User preference timezone */true + ) ); + if ( $this->getUser()->isAllowed( 'block' ) ) { + if ( $row->ipb_auto ) { + $links[] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Unblock' ), + $msg['unblocklink'], + [], + [ 'wpTarget' => "#{$row->ipb_id}" ] + ); + } else { + $links[] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Unblock', $row->ipb_address ), + $msg['unblocklink'] + ); + $links[] = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Block', $row->ipb_address ), + $msg['change-blocklink'] + ); + } + $formatted .= ' ' . Html::rawElement( + 'span', + [ 'class' => 'mw-blocklist-actions' ], + $this->msg( 'parentheses' )->rawParams( + $language->pipeList( $links ) )->escaped() + ); + } + if ( $value !== 'infinity' ) { + $timestamp = new MWTimestamp( $value ); + $formatted .= '<br />' . $this->msg( + 'ipb-blocklist-duration-left', + $language->formatDuration( + $timestamp->getTimestamp() - time(), + // reasonable output + [ + 'minutes', + 'hours', + 'days', + 'years', + ] + ) + )->escaped(); + } + break; + + case 'ipb_by': + if ( isset( $row->by_user_name ) ) { + $formatted = Linker::userLink( $value, $row->by_user_name ); + $formatted .= Linker::userToolLinks( $value, $row->by_user_name ); + } else { + $formatted = htmlspecialchars( $row->ipb_by_text ); // foreign user? + } + break; + + case 'ipb_reason': + $value = CommentStore::getStore()->getComment( 'ipb_reason', $row )->text; + $formatted = Linker::formatComment( $value ); + break; + + case 'ipb_params': + $properties = []; + if ( $row->ipb_anon_only ) { + $properties[] = htmlspecialchars( $msg['anononlyblock'] ); + } + if ( $row->ipb_create_account ) { + $properties[] = htmlspecialchars( $msg['createaccountblock'] ); + } + if ( $row->ipb_user && !$row->ipb_enable_autoblock ) { + $properties[] = htmlspecialchars( $msg['noautoblockblock'] ); + } + + if ( $row->ipb_block_email ) { + $properties[] = htmlspecialchars( $msg['emailblock'] ); + } + + if ( !$row->ipb_allow_usertalk ) { + $properties[] = htmlspecialchars( $msg['blocklist-nousertalk'] ); + } + + $formatted = $language->commaList( $properties ); + break; + + default: + $formatted = "Unable to format $name"; + break; + } + + return $formatted; + } + + function getQueryInfo() { + $commentQuery = CommentStore::getStore()->getJoin( 'ipb_reason' ); + $actorQuery = ActorMigration::newMigration()->getJoin( 'ipb_by' ); + + $info = [ + 'tables' => array_merge( + [ 'ipblocks' ], $commentQuery['tables'], $actorQuery['tables'], [ 'user' ] + ), + 'fields' => [ + 'ipb_id', + 'ipb_address', + 'ipb_user', + 'by_user_name' => 'user_name', + 'ipb_timestamp', + 'ipb_auto', + 'ipb_anon_only', + 'ipb_create_account', + 'ipb_enable_autoblock', + 'ipb_expiry', + 'ipb_range_start', + 'ipb_range_end', + 'ipb_deleted', + 'ipb_block_email', + 'ipb_allow_usertalk', + ] + $commentQuery['fields'] + $actorQuery['fields'], + 'conds' => $this->conds, + 'join_conds' => [ + 'user' => [ 'LEFT JOIN', 'user_id = ' . $actorQuery['fields']['ipb_by'] ] + ] + $commentQuery['joins'] + $actorQuery['joins'] + ]; + + # Filter out any expired blocks + $db = $this->getDatabase(); + $info['conds'][] = 'ipb_expiry > ' . $db->addQuotes( $db->timestamp() ); + + # Is the user allowed to see hidden blocks? + if ( !$this->getUser()->isAllowed( 'hideuser' ) ) { + $info['conds']['ipb_deleted'] = 0; + } + + return $info; + } + + /** + * Get total number of autoblocks at any given time + * + * @return int Total number of unexpired active autoblocks + */ + function getTotalAutoblocks() { + $dbr = $this->getDatabase(); + $res = $dbr->selectField( 'ipblocks', + [ 'COUNT(*) AS totalautoblocks' ], + [ + 'ipb_auto' => '1', + 'ipb_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() ), + ] + ); + if ( $res ) { + return $res; + } + return 0; // We found nothing + } + + protected function getTableClass() { + return parent::getTableClass() . ' mw-blocklist'; + } + + function getIndexField() { + return 'ipb_timestamp'; + } + + function getDefaultSort() { + return 'ipb_timestamp'; + } + + function isFieldSortable( $name ) { + return false; + } + + /** + * Do a LinkBatch query to minimise database load when generating all these links + * @param IResultWrapper $result + */ + function preprocessResults( $result ) { + # Do a link batch query + $lb = new LinkBatch; + $lb->setCaller( __METHOD__ ); + + foreach ( $result as $row ) { + $lb->add( NS_USER, $row->ipb_address ); + $lb->add( NS_USER_TALK, $row->ipb_address ); + + if ( isset( $row->by_user_name ) ) { + $lb->add( NS_USER, $row->by_user_name ); + $lb->add( NS_USER_TALK, $row->by_user_name ); + } + } + + $lb->execute(); + } + +} diff --git a/www/wiki/includes/specials/pagers/CategoryPager.php b/www/wiki/includes/specials/pagers/CategoryPager.php new file mode 100644 index 00000000..7db90c17 --- /dev/null +++ b/www/wiki/includes/specials/pagers/CategoryPager.php @@ -0,0 +1,115 @@ +<?php +/** + * 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 Pager + */ +use MediaWiki\Linker\LinkRenderer; + +/** + * @ingroup Pager + */ +class CategoryPager extends AlphabeticPager { + + /** + * @var LinkRenderer + */ + protected $linkRenderer; + + /** + * @param IContextSource $context + * @param string $from + * @param LinkRenderer $linkRenderer + */ + public function __construct( IContextSource $context, $from, LinkRenderer $linkRenderer + ) { + parent::__construct( $context ); + $from = str_replace( ' ', '_', $from ); + if ( $from !== '' ) { + $from = Title::capitalize( $from, NS_CATEGORY ); + $this->setOffset( $from ); + $this->setIncludeOffset( true ); + } + + $this->linkRenderer = $linkRenderer; + } + + function getQueryInfo() { + return [ + 'tables' => [ 'category' ], + 'fields' => [ 'cat_title', 'cat_pages' ], + 'options' => [ 'USE INDEX' => 'cat_title' ], + ]; + } + + function getIndexField() { + return 'cat_title'; + } + + function getDefaultQuery() { + parent::getDefaultQuery(); + unset( $this->mDefaultQuery['from'] ); + + return $this->mDefaultQuery; + } + + /* Override getBody to apply LinksBatch on resultset before actually outputting anything. */ + public function getBody() { + $batch = new LinkBatch; + + $this->mResult->rewind(); + + foreach ( $this->mResult as $row ) { + $batch->addObj( new TitleValue( NS_CATEGORY, $row->cat_title ) ); + } + $batch->execute(); + $this->mResult->rewind(); + + return parent::getBody(); + } + + function formatRow( $result ) { + $title = new TitleValue( NS_CATEGORY, $result->cat_title ); + $text = $title->getText(); + $link = $this->linkRenderer->makeLink( $title, $text ); + + $count = $this->msg( 'nmembers' )->numParams( $result->cat_pages )->escaped(); + return Html::rawElement( 'li', null, $this->getLanguage()->specialList( $link, $count ) ) . "\n"; + } + + public function getStartForm( $from ) { + $formDescriptor = [ + 'from' => [ + 'type' => 'title', + 'namespace' => NS_CATEGORY, + 'relative' => true, + 'label-message' => 'categoriesfrom', + 'name' => 'from', + 'id' => 'from', + 'size' => 20, + 'default' => $from, + ], + ]; + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ) + ->setSubmitTextMsg( 'categories-submit' ) + ->setWrapperLegendMsg( 'categories' ) + ->setMethod( 'get' ); + return $htmlForm->prepareForm()->getHTML( false ); + } + +} diff --git a/www/wiki/includes/specials/pagers/ContribsPager.php b/www/wiki/includes/specials/pagers/ContribsPager.php new file mode 100644 index 00000000..e31498ac --- /dev/null +++ b/www/wiki/includes/specials/pagers/ContribsPager.php @@ -0,0 +1,674 @@ +<?php +/** + * 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 Pager + */ + +/** + * Pager for Special:Contributions + * @ingroup Pager + */ +use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\FakeResultWrapper; +use Wikimedia\Rdbms\IDatabase; + +class ContribsPager extends RangeChronologicalPager { + + public $mDefaultDirection = IndexPager::DIR_DESCENDING; + public $messages; + public $target; + public $namespace = ''; + public $mDb; + public $preventClickjacking = false; + + /** @var IDatabase */ + public $mDbSecondary; + + /** + * @var array + */ + protected $mParentLens; + + /** + * @var TemplateParser + */ + protected $templateParser; + + function __construct( IContextSource $context, array $options ) { + parent::__construct( $context ); + + $msgs = [ + 'diff', + 'hist', + 'pipe-separator', + 'uctop' + ]; + + foreach ( $msgs as $msg ) { + $this->messages[$msg] = $this->msg( $msg )->escaped(); + } + + $this->target = isset( $options['target'] ) ? $options['target'] : ''; + $this->contribs = isset( $options['contribs'] ) ? $options['contribs'] : 'users'; + $this->namespace = isset( $options['namespace'] ) ? $options['namespace'] : ''; + $this->tagFilter = isset( $options['tagfilter'] ) ? $options['tagfilter'] : false; + $this->nsInvert = isset( $options['nsInvert'] ) ? $options['nsInvert'] : false; + $this->associated = isset( $options['associated'] ) ? $options['associated'] : false; + + $this->deletedOnly = !empty( $options['deletedOnly'] ); + $this->topOnly = !empty( $options['topOnly'] ); + $this->newOnly = !empty( $options['newOnly'] ); + $this->hideMinor = !empty( $options['hideMinor'] ); + + // Date filtering: use timestamp if available + $startTimestamp = ''; + $endTimestamp = ''; + if ( $options['start'] ) { + $startTimestamp = $options['start'] . ' 00:00:00'; + } + if ( $options['end'] ) { + $endTimestamp = $options['end'] . ' 23:59:59'; + } + $this->getDateRangeCond( $startTimestamp, $endTimestamp ); + + // This property on IndexPager is set by $this->getIndexField() in parent::__construct(). + // We need to reassign it here so that it is used when the actual query is ran. + $this->mIndexField = $this->getIndexField(); + + // Most of this code will use the 'contributions' group DB, which can map to replica DBs + // with extra user based indexes or partioning by user. The additional metadata + // queries should use a regular replica DB since the lookup pattern is not all by user. + $this->mDbSecondary = wfGetDB( DB_REPLICA ); // any random replica DB + $this->mDb = wfGetDB( DB_REPLICA, 'contributions' ); + $this->templateParser = new TemplateParser(); + } + + function getDefaultQuery() { + $query = parent::getDefaultQuery(); + $query['target'] = $this->target; + + return $query; + } + + /** + * This method basically executes the exact same code as the parent class, though with + * a hook added, to allow extensions to add additional queries. + * + * @param string $offset Index offset, inclusive + * @param int $limit Exact query limit + * @param bool $descending Query direction, false for ascending, true for descending + * @return IResultWrapper + */ + function reallyDoQuery( $offset, $limit, $descending ) { + list( $tables, $fields, $conds, $fname, $options, $join_conds ) = $this->buildQueryInfo( + $offset, + $limit, + $descending + ); + + /* + * This hook will allow extensions to add in additional queries, so they can get their data + * in My Contributions as well. Extensions should append their results to the $data array. + * + * Extension queries have to implement the navbar requirement as well. They should + * - have a column aliased as $pager->getIndexField() + * - have LIMIT set + * - have a WHERE-clause that compares the $pager->getIndexField()-equivalent column to the offset + * - have the ORDER BY specified based upon the details provided by the navbar + * + * See includes/Pager.php buildQueryInfo() method on how to build LIMIT, WHERE & ORDER BY + * + * &$data: an array of results of all contribs queries + * $pager: the ContribsPager object hooked into + * $offset: see phpdoc above + * $limit: see phpdoc above + * $descending: see phpdoc above + */ + $data = [ $this->mDb->select( + $tables, $fields, $conds, $fname, $options, $join_conds + ) ]; + Hooks::run( + 'ContribsPager::reallyDoQuery', + [ &$data, $this, $offset, $limit, $descending ] + ); + + $result = []; + + // loop all results and collect them in an array + foreach ( $data as $query ) { + foreach ( $query as $i => $row ) { + // use index column as key, allowing us to easily sort in PHP + $result[$row->{$this->getIndexField()} . "-$i"] = $row; + } + } + + // sort results + if ( $descending ) { + ksort( $result ); + } else { + krsort( $result ); + } + + // enforce limit + $result = array_slice( $result, 0, $limit ); + + // get rid of array keys + $result = array_values( $result ); + + return new FakeResultWrapper( $result ); + } + + function getQueryInfo() { + $revQuery = Revision::getQueryInfo( [ 'page', 'user' ] ); + $queryInfo = [ + 'tables' => $revQuery['tables'], + 'fields' => array_merge( $revQuery['fields'], [ 'page_is_new' ] ), + 'conds' => [], + 'options' => [], + 'join_conds' => $revQuery['joins'], + ]; + + if ( $this->contribs == 'newbie' ) { + $max = $this->mDb->selectField( 'user', 'max(user_id)', '', __METHOD__ ); + $queryInfo['conds'][] = $revQuery['fields']['rev_user'] . ' >' . (int)( $max - $max / 100 ); + # ignore local groups with the bot right + # @todo FIXME: Global groups may have 'bot' rights + $groupsWithBotPermission = User::getGroupsWithPermission( 'bot' ); + if ( count( $groupsWithBotPermission ) ) { + $queryInfo['tables'][] = 'user_groups'; + $queryInfo['conds'][] = 'ug_group IS NULL'; + $queryInfo['join_conds']['user_groups'] = [ + 'LEFT JOIN', [ + 'ug_user = ' . $revQuery['fields']['rev_user'], + 'ug_group' => $groupsWithBotPermission, + 'ug_expiry IS NULL OR ug_expiry >= ' . + $this->mDb->addQuotes( $this->mDb->timestamp() ) + ] + ]; + } + // (T140537) Disallow looking too far in the past for 'newbies' queries. If the user requested + // a timestamp offset far in the past such that there are no edits by users with user_ids in + // the range, we would end up scanning all revisions from that offset until start of time. + $queryInfo['conds'][] = 'rev_timestamp > ' . + $this->mDb->addQuotes( $this->mDb->timestamp( wfTimestamp() - 30 * 24 * 60 * 60 ) ); + } else { + $user = User::newFromName( $this->target, false ); + $ipRangeConds = $user->isAnon() ? $this->getIpRangeConds( $this->mDb, $this->target ) : null; + if ( $ipRangeConds ) { + $queryInfo['tables'][] = 'ip_changes'; + $queryInfo['join_conds']['ip_changes'] = [ + 'LEFT JOIN', [ 'ipc_rev_id = rev_id' ] + ]; + $queryInfo['conds'][] = $ipRangeConds; + } else { + // tables and joins are already handled by Revision::getQueryInfo() + $conds = ActorMigration::newMigration()->getWhere( $this->mDb, 'rev_user', $user ); + $queryInfo['conds'][] = $conds['conds']; + // Force the appropriate index to avoid bad query plans (T189026) + if ( count( $conds['orconds'] ) === 1 ) { + if ( isset( $conds['orconds']['actor'] ) ) { + // @todo: This will need changing when revision_comment_temp goes away + $queryInfo['options']['USE INDEX']['temp_rev_user'] = 'actor_timestamp'; + } else { + $queryInfo['options']['USE INDEX']['revision'] = + isset( $conds['orconds']['userid'] ) ? 'user_timestamp' : 'usertext_timestamp'; + } + } + } + } + + if ( $this->deletedOnly ) { + $queryInfo['conds'][] = 'rev_deleted != 0'; + } + + if ( $this->topOnly ) { + $queryInfo['conds'][] = 'rev_id = page_latest'; + } + + if ( $this->newOnly ) { + $queryInfo['conds'][] = 'rev_parent_id = 0'; + } + + if ( $this->hideMinor ) { + $queryInfo['conds'][] = 'rev_minor_edit = 0'; + } + + $user = $this->getUser(); + $queryInfo['conds'] = array_merge( $queryInfo['conds'], $this->getNamespaceCond() ); + + // Paranoia: avoid brute force searches (T19342) + if ( !$user->isAllowed( 'deletedhistory' ) ) { + $queryInfo['conds'][] = $this->mDb->bitAnd( 'rev_deleted', Revision::DELETED_USER ) . ' = 0'; + } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { + $queryInfo['conds'][] = $this->mDb->bitAnd( 'rev_deleted', Revision::SUPPRESSED_USER ) . + ' != ' . Revision::SUPPRESSED_USER; + } + + // For IPv6, we use ipc_rev_timestamp on ip_changes as the index field, + // which will be referenced when parsing the results of a query. + if ( self::isQueryableRange( $this->target ) ) { + $queryInfo['fields'][] = 'ipc_rev_timestamp'; + } + + ChangeTags::modifyDisplayQuery( + $queryInfo['tables'], + $queryInfo['fields'], + $queryInfo['conds'], + $queryInfo['join_conds'], + $queryInfo['options'], + $this->tagFilter + ); + + // Avoid PHP 7.1 warning from passing $this by reference + $pager = $this; + Hooks::run( 'ContribsPager::getQueryInfo', [ &$pager, &$queryInfo ] ); + + return $queryInfo; + } + + function getNamespaceCond() { + if ( $this->namespace !== '' ) { + $selectedNS = $this->mDb->addQuotes( $this->namespace ); + $eq_op = $this->nsInvert ? '!=' : '='; + $bool_op = $this->nsInvert ? 'AND' : 'OR'; + + if ( !$this->associated ) { + return [ "page_namespace $eq_op $selectedNS" ]; + } + + $associatedNS = $this->mDb->addQuotes( + MWNamespace::getAssociated( $this->namespace ) + ); + + return [ + "page_namespace $eq_op $selectedNS " . + $bool_op . + " page_namespace $eq_op $associatedNS" + ]; + } + + return []; + } + + /** + * Get SQL conditions for an IP range, if applicable + * @param IDatabase $db + * @param string $ip The IP address or CIDR + * @return string|false SQL for valid IP ranges, false if invalid + */ + private function getIpRangeConds( $db, $ip ) { + // First make sure it is a valid range and they are not outside the CIDR limit + if ( !$this->isQueryableRange( $ip ) ) { + return false; + } + + list( $start, $end ) = IP::parseRange( $ip ); + + return 'ipc_hex BETWEEN ' . $db->addQuotes( $start ) . ' AND ' . $db->addQuotes( $end ); + } + + /** + * Is the given IP a range and within the CIDR limit? + * + * @param string $ipRange + * @return bool True if it is valid + * @since 1.30 + */ + public function isQueryableRange( $ipRange ) { + $limits = $this->getConfig()->get( 'RangeContributionsCIDRLimit' ); + + $bits = IP::parseCIDR( $ipRange )[1]; + if ( + ( $bits === false ) || + ( IP::isIPv4( $ipRange ) && $bits < $limits['IPv4'] ) || + ( IP::isIPv6( $ipRange ) && $bits < $limits['IPv6'] ) + ) { + return false; + } + + return true; + } + + /** + * Override of getIndexField() in IndexPager. + * For IP ranges, it's faster to use the replicated ipc_rev_timestamp + * on the `ip_changes` table than the rev_timestamp on the `revision` table. + * @return string Name of field + */ + public function getIndexField() { + if ( $this->isQueryableRange( $this->target ) ) { + return 'ipc_rev_timestamp'; + } else { + return 'rev_timestamp'; + } + } + + function doBatchLookups() { + # Do a link batch query + $this->mResult->seek( 0 ); + $parentRevIds = []; + $this->mParentLens = []; + $batch = new LinkBatch(); + $isIpRange = $this->isQueryableRange( $this->target ); + # Give some pointers to make (last) links + foreach ( $this->mResult as $row ) { + if ( isset( $row->rev_parent_id ) && $row->rev_parent_id ) { + $parentRevIds[] = $row->rev_parent_id; + } + if ( isset( $row->rev_id ) ) { + $this->mParentLens[$row->rev_id] = $row->rev_len; + if ( $this->contribs === 'newbie' ) { // multiple users + $batch->add( NS_USER, $row->user_name ); + $batch->add( NS_USER_TALK, $row->user_name ); + } elseif ( $isIpRange ) { + // If this is an IP range, batch the IP's talk page + $batch->add( NS_USER_TALK, $row->rev_user_text ); + } + $batch->add( $row->page_namespace, $row->page_title ); + } + } + # Fetch rev_len for revisions not already scanned above + $this->mParentLens += Revision::getParentLengths( + $this->mDbSecondary, + array_diff( $parentRevIds, array_keys( $this->mParentLens ) ) + ); + $batch->execute(); + $this->mResult->seek( 0 ); + } + + /** + * @return string + */ + function getStartBody() { + return "<ul class=\"mw-contributions-list\">\n"; + } + + /** + * @return string + */ + function getEndBody() { + return "</ul>\n"; + } + + /** + * Generates each row in the contributions list. + * + * Contributions which are marked "top" are currently on top of the history. + * For these contributions, a [rollback] link is shown for users with roll- + * back privileges. The rollback link restores the most recent version that + * was not written by the target user. + * + * @todo This would probably look a lot nicer in a table. + * @param object $row + * @return string + */ + function formatRow( $row ) { + $ret = ''; + $classes = []; + $attribs = []; + + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + + /* + * There may be more than just revision rows. To make sure that we'll only be processing + * revisions here, let's _try_ to build a revision out of our row (without displaying + * notices though) and then trying to grab data from the built object. If we succeed, + * we're definitely dealing with revision data and we may proceed, if not, we'll leave it + * to extensions to subscribe to the hook to parse the row. + */ + Wikimedia\suppressWarnings(); + try { + $rev = new Revision( $row ); + $validRevision = (bool)$rev->getId(); + } catch ( Exception $e ) { + $validRevision = false; + } + Wikimedia\restoreWarnings(); + + if ( $validRevision ) { + $attribs['data-mw-revid'] = $rev->getId(); + + $page = Title::newFromRow( $row ); + $link = $linkRenderer->makeLink( + $page, + $page->getPrefixedText(), + [ 'class' => 'mw-contributions-title' ], + $page->isRedirect() ? [ 'redirect' => 'no' ] : [] + ); + # Mark current revisions + $topmarktext = ''; + $user = $this->getUser(); + + if ( $row->rev_id === $row->page_latest ) { + $topmarktext .= '<span class="mw-uctop">' . $this->messages['uctop'] . '</span>'; + $classes[] = 'mw-contributions-current'; + # Add rollback link + if ( !$row->page_is_new && $page->quickUserCan( 'rollback', $user ) + && $page->quickUserCan( 'edit', $user ) + ) { + $this->preventClickjacking(); + $topmarktext .= ' ' . Linker::generateRollback( $rev, $this->getContext() ); + } + } + # Is there a visible previous revision? + if ( $rev->userCan( Revision::DELETED_TEXT, $user ) && $rev->getParentId() !== 0 ) { + $difftext = $linkRenderer->makeKnownLink( + $page, + new HtmlArmor( $this->messages['diff'] ), + [ 'class' => 'mw-changeslist-diff' ], + [ + 'diff' => 'prev', + 'oldid' => $row->rev_id + ] + ); + } else { + $difftext = $this->messages['diff']; + } + $histlink = $linkRenderer->makeKnownLink( + $page, + new HtmlArmor( $this->messages['hist'] ), + [ 'class' => 'mw-changeslist-history' ], + [ 'action' => 'history' ] + ); + + if ( $row->rev_parent_id === null ) { + // For some reason rev_parent_id isn't populated for this row. + // Its rumoured this is true on wikipedia for some revisions (T36922). + // Next best thing is to have the total number of bytes. + $chardiff = ' <span class="mw-changeslist-separator">. .</span> '; + $chardiff .= Linker::formatRevisionSize( $row->rev_len ); + $chardiff .= ' <span class="mw-changeslist-separator">. .</span> '; + } else { + $parentLen = 0; + if ( isset( $this->mParentLens[$row->rev_parent_id] ) ) { + $parentLen = $this->mParentLens[$row->rev_parent_id]; + } + + $chardiff = ' <span class="mw-changeslist-separator">. .</span> '; + $chardiff .= ChangesList::showCharacterDifference( + $parentLen, + $row->rev_len, + $this->getContext() + ); + $chardiff .= ' <span class="mw-changeslist-separator">. .</span> '; + } + + $lang = $this->getLanguage(); + $comment = $lang->getDirMark() . Linker::revComment( $rev, false, true ); + $date = $lang->userTimeAndDate( $row->rev_timestamp, $user ); + if ( $rev->userCan( Revision::DELETED_TEXT, $user ) ) { + $d = $linkRenderer->makeKnownLink( + $page, + $date, + [ 'class' => 'mw-changeslist-date' ], + [ 'oldid' => intval( $row->rev_id ) ] + ); + } else { + $d = htmlspecialchars( $date ); + } + if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) { + $d = '<span class="history-deleted">' . $d . '</span>'; + } + + # Show user names for /newbies as there may be different users. + # Note that only unprivileged users have rows with hidden user names excluded. + # When querying for an IP range, we want to always show user and user talk links. + $userlink = ''; + if ( ( $this->contribs == 'newbie' && !$rev->isDeleted( Revision::DELETED_USER ) ) + || $this->isQueryableRange( $this->target ) ) { + $userlink = ' . . ' . $lang->getDirMark() + . Linker::userLink( $rev->getUser(), $rev->getUserText() ); + $userlink .= ' ' . $this->msg( 'parentheses' )->rawParams( + Linker::userTalkLink( $rev->getUser(), $rev->getUserText() ) )->escaped() . ' '; + } + + $flags = []; + if ( $rev->getParentId() === 0 ) { + $flags[] = ChangesList::flag( 'newpage' ); + } + + if ( $rev->isMinor() ) { + $flags[] = ChangesList::flag( 'minor' ); + } + + $del = Linker::getRevDeleteLink( $user, $rev, $page ); + if ( $del !== '' ) { + $del .= ' '; + } + + $diffHistLinks = $this->msg( 'parentheses' ) + ->rawParams( $difftext . $this->messages['pipe-separator'] . $histlink ) + ->escaped(); + + # Tags, if any. + list( $tagSummary, $newClasses ) = ChangeTags::formatSummaryRow( + $row->ts_tags, + 'contributions', + $this->getContext() + ); + $classes = array_merge( $classes, $newClasses ); + + Hooks::run( 'SpecialContributions::formatRow::flags', [ $this->getContext(), $row, &$flags ] ); + + $templateParams = [ + 'del' => $del, + 'timestamp' => $d, + 'diffHistLinks' => $diffHistLinks, + 'charDifference' => $chardiff, + 'flags' => $flags, + 'articleLink' => $link, + 'userlink' => $userlink, + 'logText' => $comment, + 'topmarktext' => $topmarktext, + 'tagSummary' => $tagSummary, + ]; + + # Denote if username is redacted for this edit + if ( $rev->isDeleted( Revision::DELETED_USER ) ) { + $templateParams['rev-deleted-user-contribs'] = + $this->msg( 'rev-deleted-user-contribs' )->escaped(); + } + + $ret = $this->templateParser->processTemplate( + 'SpecialContributionsLine', + $templateParams + ); + } + + // Let extensions add data + Hooks::run( 'ContributionsLineEnding', [ $this, &$ret, $row, &$classes, &$attribs ] ); + $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] ); + + // TODO: Handle exceptions in the catch block above. Do any extensions rely on + // receiving empty rows? + + if ( $classes === [] && $attribs === [] && $ret === '' ) { + wfDebug( "Dropping Special:Contribution row that could not be formatted\n" ); + return "<!-- Could not format Special:Contribution row. -->\n"; + } + $attribs['class'] = $classes; + + // FIXME: The signature of the ContributionsLineEnding hook makes it + // very awkward to move this LI wrapper into the template. + return Html::rawElement( 'li', $attribs, $ret ) . "\n"; + } + + /** + * Overwrite Pager function and return a helpful comment + * @return string + */ + function getSqlComment() { + if ( $this->namespace || $this->deletedOnly ) { + // potentially slow, see CR r58153 + return 'contributions page filtered for namespace or RevisionDeleted edits'; + } else { + return 'contributions page unfiltered'; + } + } + + protected function preventClickjacking() { + $this->preventClickjacking = true; + } + + /** + * @return bool + */ + public function getPreventClickjacking() { + return $this->preventClickjacking; + } + + /** + * Set up date filter options, given request data. + * + * @param array $opts Options array + * @return array Options array with processed start and end date filter options + */ + public static function processDateFilter( array $opts ) { + $start = isset( $opts['start'] ) ? $opts['start'] : ''; + $end = isset( $opts['end'] ) ? $opts['end'] : ''; + $year = isset( $opts['year'] ) ? $opts['year'] : ''; + $month = isset( $opts['month'] ) ? $opts['month'] : ''; + + if ( $start !== '' && $end !== '' && $start > $end ) { + $temp = $start; + $start = $end; + $end = $temp; + } + + // If year/month legacy filtering options are set, convert them to display the new stamp + if ( $year !== '' || $month !== '' ) { + // Reuse getDateCond logic, but subtract a day because + // the endpoints of our date range appear inclusive + // but the internal end offsets are always exclusive + $legacyTimestamp = ReverseChronologicalPager::getOffsetDate( $year, $month ); + $legacyDateTime = new DateTime( $legacyTimestamp->getTimestamp( TS_ISO_8601 ) ); + $legacyDateTime = $legacyDateTime->modify( '-1 day' ); + + // Clear the new timestamp range options if used and + // replace with the converted legacy timestamp + $start = ''; + $end = $legacyDateTime->format( 'Y-m-d' ); + } + + $opts['start'] = $start; + $opts['end'] = $end; + + return $opts; + } +} diff --git a/www/wiki/includes/specials/pagers/DeletedContribsPager.php b/www/wiki/includes/specials/pagers/DeletedContribsPager.php new file mode 100644 index 00000000..f3de64d6 --- /dev/null +++ b/www/wiki/includes/specials/pagers/DeletedContribsPager.php @@ -0,0 +1,365 @@ +<?php +/** + * 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 Pager + */ + +/** + * @ingroup Pager + */ +use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\FakeResultWrapper; + +class DeletedContribsPager extends IndexPager { + + public $mDefaultDirection = IndexPager::DIR_DESCENDING; + public $messages; + public $target; + public $namespace = ''; + public $mDb; + + /** + * @var string Navigation bar with paging links. + */ + protected $mNavigationBar; + + function __construct( IContextSource $context, $target, $namespace = false ) { + parent::__construct( $context ); + $msgs = [ 'deletionlog', 'undeleteviewlink', 'diff' ]; + foreach ( $msgs as $msg ) { + $this->messages[$msg] = $this->msg( $msg )->text(); + } + $this->target = $target; + $this->namespace = $namespace; + $this->mDb = wfGetDB( DB_REPLICA, 'contributions' ); + } + + function getDefaultQuery() { + $query = parent::getDefaultQuery(); + $query['target'] = $this->target; + + return $query; + } + + function getQueryInfo() { + $userCond = [ + // ->getJoin() below takes care of any joins needed + ActorMigration::newMigration()->getWhere( + wfGetDB( DB_REPLICA ), 'ar_user', User::newFromName( $this->target, false ), false + )['conds'] + ]; + $conds = array_merge( $userCond, $this->getNamespaceCond() ); + $user = $this->getUser(); + // Paranoia: avoid brute force searches (T19792) + if ( !$user->isAllowed( 'deletedhistory' ) ) { + $conds[] = $this->mDb->bitAnd( 'ar_deleted', Revision::DELETED_USER ) . ' = 0'; + } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { + $conds[] = $this->mDb->bitAnd( 'ar_deleted', Revision::SUPPRESSED_USER ) . + ' != ' . Revision::SUPPRESSED_USER; + } + + $commentQuery = CommentStore::getStore()->getJoin( 'ar_comment' ); + $actorQuery = ActorMigration::newMigration()->getJoin( 'ar_user' ); + + return [ + 'tables' => [ 'archive' ] + $commentQuery['tables'] + $actorQuery['tables'], + 'fields' => [ + 'ar_rev_id', 'ar_namespace', 'ar_title', 'ar_timestamp', + 'ar_minor_edit', 'ar_deleted' + ] + $commentQuery['fields'] + $actorQuery['fields'], + 'conds' => $conds, + 'options' => [], + 'join_conds' => $commentQuery['joins'] + $actorQuery['joins'], + ]; + } + + /** + * This method basically executes the exact same code as the parent class, though with + * a hook added, to allow extensions to add additional queries. + * + * @param string $offset Index offset, inclusive + * @param int $limit Exact query limit + * @param bool $descending Query direction, false for ascending, true for descending + * @return IResultWrapper + */ + function reallyDoQuery( $offset, $limit, $descending ) { + $data = [ parent::reallyDoQuery( $offset, $limit, $descending ) ]; + + // This hook will allow extensions to add in additional queries, nearly + // identical to ContribsPager::reallyDoQuery. + Hooks::run( + 'DeletedContribsPager::reallyDoQuery', + [ &$data, $this, $offset, $limit, $descending ] + ); + + $result = []; + + // loop all results and collect them in an array + foreach ( $data as $query ) { + foreach ( $query as $i => $row ) { + // use index column as key, allowing us to easily sort in PHP + $result[$row->{$this->getIndexField()} . "-$i"] = $row; + } + } + + // sort results + if ( $descending ) { + ksort( $result ); + } else { + krsort( $result ); + } + + // enforce limit + $result = array_slice( $result, 0, $limit ); + + // get rid of array keys + $result = array_values( $result ); + + return new FakeResultWrapper( $result ); + } + + function getIndexField() { + return 'ar_timestamp'; + } + + function getStartBody() { + return "<ul>\n"; + } + + function getEndBody() { + return "</ul>\n"; + } + + function getNavigationBar() { + if ( isset( $this->mNavigationBar ) ) { + return $this->mNavigationBar; + } + + $linkTexts = [ + 'prev' => $this->msg( 'pager-newer-n' )->numParams( $this->mLimit )->escaped(), + 'next' => $this->msg( 'pager-older-n' )->numParams( $this->mLimit )->escaped(), + 'first' => $this->msg( 'histlast' )->escaped(), + 'last' => $this->msg( 'histfirst' )->escaped() + ]; + + $pagingLinks = $this->getPagingLinks( $linkTexts ); + $limitLinks = $this->getLimitLinks(); + $lang = $this->getLanguage(); + $limits = $lang->pipeList( $limitLinks ); + + $firstLast = $lang->pipeList( [ $pagingLinks['first'], $pagingLinks['last'] ] ); + $firstLast = $this->msg( 'parentheses' )->rawParams( $firstLast )->escaped(); + $prevNext = $this->msg( 'viewprevnext' ) + ->rawParams( + $pagingLinks['prev'], + $pagingLinks['next'], + $limits + )->escaped(); + $separator = $this->msg( 'word-separator' )->escaped(); + $this->mNavigationBar = $firstLast . $separator . $prevNext; + + return $this->mNavigationBar; + } + + function getNamespaceCond() { + if ( $this->namespace !== '' ) { + return [ 'ar_namespace' => (int)$this->namespace ]; + } else { + return []; + } + } + + /** + * Generates each row in the contributions list. + * + * @todo This would probably look a lot nicer in a table. + * @param stdClass $row + * @return string + */ + function formatRow( $row ) { + $ret = ''; + $classes = []; + $attribs = []; + + /* + * There may be more than just revision rows. To make sure that we'll only be processing + * revisions here, let's _try_ to build a revision out of our row (without displaying + * notices though) and then trying to grab data from the built object. If we succeed, + * we're definitely dealing with revision data and we may proceed, if not, we'll leave it + * to extensions to subscribe to the hook to parse the row. + */ + Wikimedia\suppressWarnings(); + try { + $rev = Revision::newFromArchiveRow( $row ); + $validRevision = (bool)$rev->getId(); + } catch ( Exception $e ) { + $validRevision = false; + } + Wikimedia\restoreWarnings(); + + if ( $validRevision ) { + $attribs['data-mw-revid'] = $rev->getId(); + $ret = $this->formatRevisionRow( $row ); + } + + // Let extensions add data + Hooks::run( 'DeletedContributionsLineEnding', [ $this, &$ret, $row, &$classes, &$attribs ] ); + $attribs = wfArrayFilterByKey( $attribs, [ Sanitizer::class, 'isReservedDataAttribute' ] ); + + if ( $classes === [] && $attribs === [] && $ret === '' ) { + wfDebug( "Dropping Special:DeletedContribution row that could not be formatted\n" ); + $ret = "<!-- Could not format Special:DeletedContribution row. -->\n"; + } else { + $attribs['class'] = $classes; + $ret = Html::rawElement( 'li', $attribs, $ret ) . "\n"; + } + + return $ret; + } + + /** + * Generates each row in the contributions list for archive entries. + * + * Contributions which are marked "top" are currently on top of the history. + * For these contributions, a [rollback] link is shown for users with sysop + * privileges. The rollback link restores the most recent version that was not + * written by the target user. + * + * @todo This would probably look a lot nicer in a table. + * @param stdClass $row + * @return string + */ + function formatRevisionRow( $row ) { + $page = Title::makeTitle( $row->ar_namespace, $row->ar_title ); + + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + + $rev = new Revision( [ + 'title' => $page, + 'id' => $row->ar_rev_id, + 'comment' => CommentStore::getStore()->getComment( 'ar_comment', $row )->text, + 'user' => $row->ar_user, + 'user_text' => $row->ar_user_text, + 'actor' => isset( $row->ar_actor ) ? $row->ar_actor : null, + 'timestamp' => $row->ar_timestamp, + 'minor_edit' => $row->ar_minor_edit, + 'deleted' => $row->ar_deleted, + ] ); + + $undelete = SpecialPage::getTitleFor( 'Undelete' ); + + $logs = SpecialPage::getTitleFor( 'Log' ); + $dellog = $linkRenderer->makeKnownLink( + $logs, + $this->messages['deletionlog'], + [], + [ + 'type' => 'delete', + 'page' => $page->getPrefixedText() + ] + ); + + $reviewlink = $linkRenderer->makeKnownLink( + SpecialPage::getTitleFor( 'Undelete', $page->getPrefixedDBkey() ), + $this->messages['undeleteviewlink'] + ); + + $user = $this->getUser(); + + if ( $user->isAllowed( 'deletedtext' ) ) { + $last = $linkRenderer->makeKnownLink( + $undelete, + $this->messages['diff'], + [], + [ + 'target' => $page->getPrefixedText(), + 'timestamp' => $rev->getTimestamp(), + 'diff' => 'prev' + ] + ); + } else { + $last = htmlspecialchars( $this->messages['diff'] ); + } + + $comment = Linker::revComment( $rev ); + $date = $this->getLanguage()->userTimeAndDate( $rev->getTimestamp(), $user ); + + if ( !$user->isAllowed( 'undelete' ) || !$rev->userCan( Revision::DELETED_TEXT, $user ) ) { + $link = htmlspecialchars( $date ); // unusable link + } else { + $link = $linkRenderer->makeKnownLink( + $undelete, + $date, + [ 'class' => 'mw-changeslist-date' ], + [ + 'target' => $page->getPrefixedText(), + 'timestamp' => $rev->getTimestamp() + ] + ); + } + // Style deleted items + if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) { + $link = '<span class="history-deleted">' . $link . '</span>'; + } + + $pagelink = $linkRenderer->makeLink( + $page, + null, + [ 'class' => 'mw-changeslist-title' ] + ); + + if ( $rev->isMinor() ) { + $mflag = ChangesList::flag( 'minor' ); + } else { + $mflag = ''; + } + + // Revision delete link + $del = Linker::getRevDeleteLink( $user, $rev, $page ); + if ( $del ) { + $del .= ' '; + } + + $tools = Html::rawElement( + 'span', + [ 'class' => 'mw-deletedcontribs-tools' ], + $this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList( + [ $last, $dellog, $reviewlink ] ) )->escaped() + ); + + $separator = '<span class="mw-changeslist-separator">. .</span>'; + $ret = "{$del}{$link} {$tools} {$separator} {$mflag} {$pagelink} {$comment}"; + + # Denote if username is redacted for this edit + if ( $rev->isDeleted( Revision::DELETED_USER ) ) { + $ret .= " <strong>" . $this->msg( 'rev-deleted-user-contribs' )->escaped() . "</strong>"; + } + + return $ret; + } + + /** + * Get the Database object in use + * + * @return IDatabase + */ + public function getDatabase() { + return $this->mDb; + } +} diff --git a/www/wiki/includes/specials/pagers/ImageListPager.php b/www/wiki/includes/specials/pagers/ImageListPager.php new file mode 100644 index 00000000..bb4f0b34 --- /dev/null +++ b/www/wiki/includes/specials/pagers/ImageListPager.php @@ -0,0 +1,628 @@ +<?php +/** + * 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 Pager + */ + +/** + * @ingroup Pager + */ +use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\IResultWrapper; +use Wikimedia\Rdbms\FakeResultWrapper; + +class ImageListPager extends TablePager { + + protected $mFieldNames = null; + + // Subclasses should override buildQueryConds instead of using $mQueryConds variable. + protected $mQueryConds = []; + + protected $mUserName = null; + + /** + * The relevant user + * + * @var User|null + */ + protected $mUser = null; + + protected $mSearch = ''; + + protected $mIncluding = false; + + protected $mShowAll = false; + + protected $mTableName = 'image'; + + function __construct( IContextSource $context, $userName = null, $search = '', + $including = false, $showAll = false + ) { + $this->setContext( $context ); + $this->mIncluding = $including; + $this->mShowAll = $showAll; + + if ( $userName !== null && $userName !== '' ) { + $nt = Title::makeTitleSafe( NS_USER, $userName ); + if ( is_null( $nt ) ) { + $this->outputUserDoesNotExist( $userName ); + } else { + $this->mUserName = $nt->getText(); + $user = User::newFromName( $this->mUserName, false ); + if ( $user ) { + $this->mUser = $user; + } + if ( !$user || ( $user->isAnon() && !User::isIP( $user->getName() ) ) ) { + $this->outputUserDoesNotExist( $userName ); + } + } + } + + if ( $search !== '' && !$this->getConfig()->get( 'MiserMode' ) ) { + $this->mSearch = $search; + $nt = Title::newFromText( $this->mSearch ); + + if ( $nt ) { + $dbr = wfGetDB( DB_REPLICA ); + $this->mQueryConds[] = 'LOWER(img_name)' . + $dbr->buildLike( $dbr->anyString(), + strtolower( $nt->getDBkey() ), $dbr->anyString() ); + } + } + + if ( !$including ) { + if ( $this->getRequest()->getText( 'sort', 'img_date' ) == 'img_date' ) { + $this->mDefaultDirection = IndexPager::DIR_DESCENDING; + } else { + $this->mDefaultDirection = IndexPager::DIR_ASCENDING; + } + } else { + $this->mDefaultDirection = IndexPager::DIR_DESCENDING; + } + + parent::__construct( $context ); + } + + /** + * Get the user relevant to the ImageList + * + * @return User|null + */ + function getRelevantUser() { + return $this->mUser; + } + + /** + * Add a message to the output stating that the user doesn't exist + * + * @param string $userName Unescaped user name + */ + protected function outputUserDoesNotExist( $userName ) { + $this->getOutput()->wrapWikiMsg( + "<div class=\"mw-userpage-userdoesnotexist error\">\n$1\n</div>", + [ + 'listfiles-userdoesnotexist', + wfEscapeWikiText( $userName ), + ] + ); + } + + /** + * Build the where clause of the query. + * + * Replaces the older mQueryConds member variable. + * @param string $table Either "image" or "oldimage" + * @return array The query conditions. + */ + protected function buildQueryConds( $table ) { + $prefix = $table === 'image' ? 'img' : 'oi'; + $conds = []; + + if ( !is_null( $this->mUserName ) ) { + // getQueryInfoReal() should have handled the tables and joins. + $dbr = wfGetDB( DB_REPLICA ); + $actorWhere = ActorMigration::newMigration()->getWhere( + $dbr, + $prefix . '_user', + User::newFromName( $this->mUserName, false ) + ); + $conds[] = $actorWhere['conds']; + } + + if ( $this->mSearch !== '' ) { + $nt = Title::newFromText( $this->mSearch ); + if ( $nt ) { + $dbr = wfGetDB( DB_REPLICA ); + $conds[] = 'LOWER(' . $prefix . '_name)' . + $dbr->buildLike( $dbr->anyString(), + strtolower( $nt->getDBkey() ), $dbr->anyString() ); + } + } + + if ( $table === 'oldimage' ) { + // Don't want to deal with revdel. + // Future fixme: Show partial information as appropriate. + // Would have to be careful about filtering by username when username is deleted. + $conds['oi_deleted'] = 0; + } + + // Add mQueryConds in case anyone was subclassing and using the old variable. + return $conds + $this->mQueryConds; + } + + /** + * @return array + */ + function getFieldNames() { + if ( !$this->mFieldNames ) { + $this->mFieldNames = [ + 'img_timestamp' => $this->msg( 'listfiles_date' )->text(), + 'img_name' => $this->msg( 'listfiles_name' )->text(), + 'thumb' => $this->msg( 'listfiles_thumb' )->text(), + 'img_size' => $this->msg( 'listfiles_size' )->text(), + ]; + if ( is_null( $this->mUserName ) ) { + // Do not show username if filtering by username + $this->mFieldNames['img_user_text'] = $this->msg( 'listfiles_user' )->text(); + } + // img_description down here, in order so that its still after the username field. + $this->mFieldNames['img_description'] = $this->msg( 'listfiles_description' )->text(); + + if ( !$this->getConfig()->get( 'MiserMode' ) && !$this->mShowAll ) { + $this->mFieldNames['count'] = $this->msg( 'listfiles_count' )->text(); + } + if ( $this->mShowAll ) { + $this->mFieldNames['top'] = $this->msg( 'listfiles-latestversion' )->text(); + } + } + + return $this->mFieldNames; + } + + function isFieldSortable( $field ) { + if ( $this->mIncluding ) { + return false; + } + $sortable = [ 'img_timestamp', 'img_name', 'img_size' ]; + /* For reference, the indicies we can use for sorting are: + * On the image table: img_user_timestamp/img_usertext_timestamp/img_actor_timestamp, + * img_size, img_timestamp + * On oldimage: oi_usertext_timestamp/oi_actor_timestamp, oi_name_timestamp + * + * In particular that means we cannot sort by timestamp when not filtering + * by user and including old images in the results. Which is sad. + */ + if ( $this->getConfig()->get( 'MiserMode' ) && !is_null( $this->mUserName ) ) { + // If we're sorting by user, the index only supports sorting by time. + if ( $field === 'img_timestamp' ) { + return true; + } else { + return false; + } + } elseif ( $this->getConfig()->get( 'MiserMode' ) + && $this->mShowAll /* && mUserName === null */ + ) { + // no oi_timestamp index, so only alphabetical sorting in this case. + if ( $field === 'img_name' ) { + return true; + } else { + return false; + } + } + + return in_array( $field, $sortable ); + } + + function getQueryInfo() { + // Hacky Hacky Hacky - I want to get query info + // for two different tables, without reimplementing + // the pager class. + $qi = $this->getQueryInfoReal( $this->mTableName ); + + return $qi; + } + + /** + * Actually get the query info. + * + * This is to allow displaying both stuff from image and oldimage table. + * + * This is a bit hacky. + * + * @param string $table Either 'image' or 'oldimage' + * @return array Query info + */ + protected function getQueryInfoReal( $table ) { + $prefix = $table === 'oldimage' ? 'oi' : 'img'; + + $tables = [ $table ]; + $fields = $this->getFieldNames(); + unset( $fields['img_description'] ); + unset( $fields['img_user_text'] ); + $fields = array_keys( $fields ); + + if ( $table === 'oldimage' ) { + foreach ( $fields as $id => &$field ) { + if ( substr( $field, 0, 4 ) !== 'img_' ) { + continue; + } + $field = $prefix . substr( $field, 3 ) . ' AS ' . $field; + } + $fields[array_search( 'top', $fields )] = "'no' AS top"; + } else { + if ( $this->mShowAll ) { + $fields[array_search( 'top', $fields )] = "'yes' AS top"; + } + } + $fields[array_search( 'thumb', $fields )] = $prefix . '_name AS thumb'; + + $options = $join_conds = []; + + # Description field + $commentQuery = CommentStore::getStore()->getJoin( $prefix . '_description' ); + $tables += $commentQuery['tables']; + $fields += $commentQuery['fields']; + $join_conds += $commentQuery['joins']; + $fields['description_field'] = "'{$prefix}_description'"; + + # User fields + $actorQuery = ActorMigration::newMigration()->getJoin( $prefix . '_user' ); + $tables += $actorQuery['tables']; + $join_conds += $actorQuery['joins']; + $fields['img_user'] = $actorQuery['fields'][$prefix . '_user']; + $fields['img_user_text'] = $actorQuery['fields'][$prefix . '_user_text']; + $fields['img_actor'] = $actorQuery['fields'][$prefix . '_actor']; + + # Depends on $wgMiserMode + # Will also not happen if mShowAll is true. + if ( isset( $this->mFieldNames['count'] ) ) { + $tables[] = 'oldimage'; + + # Need to rewrite this one + foreach ( $fields as &$field ) { + if ( $field == 'count' ) { + $field = 'COUNT(oi_archive_name) AS count'; + } + } + unset( $field ); + + $columnlist = preg_grep( '/^img/', array_keys( $this->getFieldNames() ) ); + $options = [ 'GROUP BY' => array_merge( [ $fields['img_user'] ], $columnlist ) ]; + $join_conds['oldimage'] = [ 'LEFT JOIN', 'oi_name = img_name' ]; + } + + return [ + 'tables' => $tables, + 'fields' => $fields, + 'conds' => $this->buildQueryConds( $table ), + 'options' => $options, + 'join_conds' => $join_conds + ]; + } + + /** + * Override reallyDoQuery to mix together two queries. + * + * @note $asc is named $descending in IndexPager base class. However + * it is true when the order is ascending, and false when the order + * is descending, so I renamed it to $asc here. + * @param int $offset + * @param int $limit + * @param bool $asc + * @return array + * @throws MWException + */ + function reallyDoQuery( $offset, $limit, $asc ) { + $prevTableName = $this->mTableName; + $this->mTableName = 'image'; + list( $tables, $fields, $conds, $fname, $options, $join_conds ) = + $this->buildQueryInfo( $offset, $limit, $asc ); + $imageRes = $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds ); + $this->mTableName = $prevTableName; + + if ( !$this->mShowAll ) { + return $imageRes; + } + + $this->mTableName = 'oldimage'; + + # Hacky... + $oldIndex = $this->mIndexField; + if ( substr( $this->mIndexField, 0, 4 ) !== 'img_' ) { + throw new MWException( "Expected to be sorting on an image table field" ); + } + $this->mIndexField = 'oi_' . substr( $this->mIndexField, 4 ); + + list( $tables, $fields, $conds, $fname, $options, $join_conds ) = + $this->buildQueryInfo( $offset, $limit, $asc ); + $oldimageRes = $this->mDb->select( $tables, $fields, $conds, $fname, $options, $join_conds ); + + $this->mTableName = $prevTableName; + $this->mIndexField = $oldIndex; + + return $this->combineResult( $imageRes, $oldimageRes, $limit, $asc ); + } + + /** + * Combine results from 2 tables. + * + * Note: This will throw away some results + * + * @param IResultWrapper $res1 + * @param IResultWrapper $res2 + * @param int $limit + * @param bool $ascending See note about $asc in $this->reallyDoQuery + * @return FakeResultWrapper $res1 and $res2 combined + */ + protected function combineResult( $res1, $res2, $limit, $ascending ) { + $res1->rewind(); + $res2->rewind(); + $topRes1 = $res1->next(); + $topRes2 = $res2->next(); + $resultArray = []; + for ( $i = 0; $i < $limit && $topRes1 && $topRes2; $i++ ) { + if ( strcmp( $topRes1->{$this->mIndexField}, $topRes2->{$this->mIndexField} ) > 0 ) { + if ( !$ascending ) { + $resultArray[] = $topRes1; + $topRes1 = $res1->next(); + } else { + $resultArray[] = $topRes2; + $topRes2 = $res2->next(); + } + } else { + if ( !$ascending ) { + $resultArray[] = $topRes2; + $topRes2 = $res2->next(); + } else { + $resultArray[] = $topRes1; + $topRes1 = $res1->next(); + } + } + } + + for ( ; $i < $limit && $topRes1; $i++ ) { + $resultArray[] = $topRes1; + $topRes1 = $res1->next(); + } + + for ( ; $i < $limit && $topRes2; $i++ ) { + $resultArray[] = $topRes2; + $topRes2 = $res2->next(); + } + + return new FakeResultWrapper( $resultArray ); + } + + function getDefaultSort() { + if ( $this->mShowAll && $this->getConfig()->get( 'MiserMode' ) && is_null( $this->mUserName ) ) { + // Unfortunately no index on oi_timestamp. + return 'img_name'; + } else { + return 'img_timestamp'; + } + } + + function doBatchLookups() { + $userIds = []; + $this->mResult->seek( 0 ); + foreach ( $this->mResult as $row ) { + $userIds[] = $row->img_user; + } + # Do a link batch query for names and userpages + UserCache::singleton()->doQuery( $userIds, [ 'userpage' ], __METHOD__ ); + } + + /** + * @param string $field + * @param string $value + * @return Message|string|int The return type depends on the value of $field: + * - thumb: string + * - img_timestamp: string + * - img_name: string + * - img_user_text: string + * - img_size: string + * - img_description: string + * - count: int + * - top: Message + * @throws MWException + */ + function formatValue( $field, $value ) { + $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer(); + switch ( $field ) { + case 'thumb': + $opt = [ 'time' => wfTimestamp( TS_MW, $this->mCurrentRow->img_timestamp ) ]; + $file = RepoGroup::singleton()->getLocalRepo()->findFile( $value, $opt ); + // If statement for paranoia + if ( $file ) { + $thumb = $file->transform( [ 'width' => 180, 'height' => 360 ] ); + if ( $thumb ) { + return $thumb->toHtml( [ 'desc-link' => true ] ); + } else { + return wfMessage( 'thumbnail_error', '' )->escaped(); + } + } else { + return htmlspecialchars( $value ); + } + case 'img_timestamp': + // We may want to make this a link to the "old" version when displaying old files + return htmlspecialchars( $this->getLanguage()->userTimeAndDate( $value, $this->getUser() ) ); + case 'img_name': + static $imgfile = null; + if ( $imgfile === null ) { + $imgfile = $this->msg( 'imgfile' )->text(); + } + + // Weird files can maybe exist? T24227 + $filePage = Title::makeTitleSafe( NS_FILE, $value ); + if ( $filePage ) { + $link = $linkRenderer->makeKnownLink( + $filePage, + $filePage->getText() + ); + $download = Xml::element( 'a', + [ 'href' => wfLocalFile( $filePage )->getUrl() ], + $imgfile + ); + $download = $this->msg( 'parentheses' )->rawParams( $download )->escaped(); + + // Add delete links if allowed + // From https://github.com/Wikia/app/pull/3859 + if ( $filePage->userCan( 'delete', $this->getUser() ) ) { + $deleteMsg = $this->msg( 'listfiles-delete' )->text(); + + $delete = $linkRenderer->makeKnownLink( + $filePage, $deleteMsg, [], [ 'action' => 'delete' ] + ); + $delete = $this->msg( 'parentheses' )->rawParams( $delete )->escaped(); + + return "$link $download $delete"; + } + + return "$link $download"; + } else { + return htmlspecialchars( $value ); + } + case 'img_user_text': + if ( $this->mCurrentRow->img_user ) { + $name = User::whoIs( $this->mCurrentRow->img_user ); + $link = $linkRenderer->makeLink( + Title::makeTitle( NS_USER, $name ), + $name + ); + } else { + $link = htmlspecialchars( $value ); + } + + return $link; + case 'img_size': + return htmlspecialchars( $this->getLanguage()->formatSize( $value ) ); + case 'img_description': + $field = $this->mCurrentRow->description_field; + $value = CommentStore::getStore()->getComment( $field, $this->mCurrentRow )->text; + return Linker::formatComment( $value ); + case 'count': + return $this->getLanguage()->formatNum( intval( $value ) + 1 ); + case 'top': + // Messages: listfiles-latestversion-yes, listfiles-latestversion-no + return $this->msg( 'listfiles-latestversion-' . $value ); + default: + throw new MWException( "Unknown field '$field'" ); + } + } + + function getForm() { + $fields = []; + $fields['limit'] = [ + 'type' => 'select', + 'name' => 'limit', + 'label-message' => 'table_pager_limit_label', + 'options' => $this->getLimitSelectList(), + 'default' => $this->mLimit, + ]; + + if ( !$this->getConfig()->get( 'MiserMode' ) ) { + $fields['ilsearch'] = [ + 'type' => 'text', + 'name' => 'ilsearch', + 'id' => 'mw-ilsearch', + 'label-message' => 'listfiles_search_for', + 'default' => $this->mSearch, + 'size' => '40', + 'maxlength' => '255', + ]; + } + + $this->getOutput()->addModules( 'mediawiki.userSuggest' ); + $fields['user'] = [ + 'type' => 'text', + 'name' => 'user', + 'id' => 'mw-listfiles-user', + 'label-message' => 'username', + 'default' => $this->mUserName, + 'size' => '40', + 'maxlength' => '255', + 'cssclass' => 'mw-autocomplete-user', // used by mediawiki.userSuggest + ]; + + $fields['ilshowall'] = [ + 'type' => 'check', + 'name' => 'ilshowall', + 'id' => 'mw-listfiles-show-all', + 'label-message' => 'listfiles-show-all', + 'default' => $this->mShowAll, + ]; + + $query = $this->getRequest()->getQueryValues(); + unset( $query['title'] ); + unset( $query['limit'] ); + unset( $query['ilsearch'] ); + unset( $query['ilshowall'] ); + unset( $query['user'] ); + + $form = new HTMLForm( $fields, $this->getContext() ); + + $form->setMethod( 'get' ); + $form->setTitle( $this->getTitle() ); + $form->setId( 'mw-listfiles-form' ); + $form->setWrapperLegendMsg( 'listfiles' ); + $form->setSubmitTextMsg( 'table_pager_limit_submit' ); + $form->addHiddenFields( $query ); + + $form->prepareForm(); + $form->displayForm( '' ); + } + + protected function getTableClass() { + return parent::getTableClass() . ' listfiles'; + } + + protected function getNavClass() { + return parent::getNavClass() . ' listfiles_nav'; + } + + protected function getSortHeaderClass() { + return parent::getSortHeaderClass() . ' listfiles_sort'; + } + + function getPagingQueries() { + $queries = parent::getPagingQueries(); + if ( !is_null( $this->mUserName ) ) { + # Append the username to the query string + foreach ( $queries as &$query ) { + if ( $query !== false ) { + $query['user'] = $this->mUserName; + } + } + } + + return $queries; + } + + function getDefaultQuery() { + $queries = parent::getDefaultQuery(); + if ( !isset( $queries['user'] ) && !is_null( $this->mUserName ) ) { + $queries['user'] = $this->mUserName; + } + + return $queries; + } + + function getTitle() { + return SpecialPage::getTitleFor( 'Listfiles' ); + } +} diff --git a/www/wiki/includes/specials/pagers/MergeHistoryPager.php b/www/wiki/includes/specials/pagers/MergeHistoryPager.php new file mode 100644 index 00000000..6a8f7da7 --- /dev/null +++ b/www/wiki/includes/specials/pagers/MergeHistoryPager.php @@ -0,0 +1,100 @@ +<?php +/** + * 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 Pager + */ + +/** + * @ingroup Pager + */ +class MergeHistoryPager extends ReverseChronologicalPager { + + /** @var SpecialMergeHistory */ + public $mForm; + + /** @var array */ + public $mConds; + + function __construct( SpecialMergeHistory $form, $conds, Title $source, Title $dest ) { + $this->mForm = $form; + $this->mConds = $conds; + $this->title = $source; + $this->articleID = $source->getArticleID(); + + $dbr = wfGetDB( DB_REPLICA ); + $maxtimestamp = $dbr->selectField( + 'revision', + 'MIN(rev_timestamp)', + [ 'rev_page' => $dest->getArticleID() ], + __METHOD__ + ); + $this->maxTimestamp = $maxtimestamp; + + parent::__construct( $form->getContext() ); + } + + function getStartBody() { + # Do a link batch query + $this->mResult->seek( 0 ); + $batch = new LinkBatch(); + # Give some pointers to make (last) links + $this->mForm->prevId = []; + $rev_id = null; + foreach ( $this->mResult as $row ) { + $batch->addObj( Title::makeTitleSafe( NS_USER, $row->user_name ) ); + $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->user_name ) ); + + if ( isset( $rev_id ) ) { + if ( $rev_id > $row->rev_id ) { + $this->mForm->prevId[$rev_id] = $row->rev_id; + } elseif ( $rev_id < $row->rev_id ) { + $this->mForm->prevId[$row->rev_id] = $rev_id; + } + } + + $rev_id = $row->rev_id; + } + + $batch->execute(); + $this->mResult->seek( 0 ); + + return ''; + } + + function formatRow( $row ) { + return $this->mForm->formatRevisionRow( $row ); + } + + function getQueryInfo() { + $conds = $this->mConds; + $conds['rev_page'] = $this->articleID; + $conds[] = "rev_timestamp < " . $this->mDb->addQuotes( $this->maxTimestamp ); + + $revQuery = Revision::getQueryInfo( [ 'page', 'user' ] ); + return [ + 'tables' => $revQuery['tables'], + 'fields' => $revQuery['fields'], + 'conds' => $conds, + 'join_conds' => $revQuery['joins'] + ]; + } + + function getIndexField() { + return 'rev_timestamp'; + } +} diff --git a/www/wiki/includes/specials/pagers/NewFilesPager.php b/www/wiki/includes/specials/pagers/NewFilesPager.php new file mode 100644 index 00000000..c214f1f7 --- /dev/null +++ b/www/wiki/includes/specials/pagers/NewFilesPager.php @@ -0,0 +1,208 @@ +<?php +/** + * 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 Pager + */ + +/** + * @ingroup Pager + */ +use MediaWiki\MediaWikiServices; + +class NewFilesPager extends RangeChronologicalPager { + + /** + * @var ImageGalleryBase + */ + protected $gallery; + + /** + * @var FormOptions + */ + protected $opts; + + /** + * @param IContextSource $context + * @param FormOptions $opts + */ + function __construct( IContextSource $context, FormOptions $opts ) { + parent::__construct( $context ); + + $this->opts = $opts; + $this->setLimit( $opts->getValue( 'limit' ) ); + + $startTimestamp = ''; + $endTimestamp = ''; + if ( $opts->getValue( 'start' ) ) { + $startTimestamp = $opts->getValue( 'start' ) . ' 00:00:00'; + } + if ( $opts->getValue( 'end' ) ) { + $endTimestamp = $opts->getValue( 'end' ) . ' 23:59:59'; + } + $this->getDateRangeCond( $startTimestamp, $endTimestamp ); + } + + function getQueryInfo() { + $opts = $this->opts; + $conds = []; + $imgQuery = LocalFile::getQueryInfo(); + $tables = $imgQuery['tables']; + $fields = [ 'img_name', 'img_timestamp' ] + $imgQuery['fields']; + $options = []; + $jconds = $imgQuery['joins']; + + $user = $opts->getValue( 'user' ); + if ( $user !== '' ) { + $conds[] = ActorMigration::newMigration() + ->getWhere( wfGetDB( DB_REPLICA ), 'img_user', User::newFromName( $user, false ) )['conds']; + } + + if ( $opts->getValue( 'newbies' ) ) { + // newbie = most recent 1% of users + $dbr = wfGetDB( DB_REPLICA ); + $max = $dbr->selectField( 'user', 'max(user_id)', '', __METHOD__ ); + $conds[] = $imgQuery['fields']['img_user'] . ' >' . (int)( $max - $max / 100 ); + + // there's no point in looking for new user activity in a far past; + // beyond a certain point, we'd just end up scanning the rest of the + // table even though the users we're looking for didn't yet exist... + // see T140537, (for ContribsPages, but similar to this) + $conds[] = 'img_timestamp > ' . + $dbr->addQuotes( $dbr->timestamp( wfTimestamp() - 30 * 24 * 60 * 60 ) ); + } + + if ( !$opts->getValue( 'showbots' ) ) { + $groupsWithBotPermission = User::getGroupsWithPermission( 'bot' ); + + if ( count( $groupsWithBotPermission ) ) { + $dbr = wfGetDB( DB_REPLICA ); + $tables[] = 'user_groups'; + $conds[] = 'ug_group IS NULL'; + $jconds['user_groups'] = [ + 'LEFT JOIN', + [ + 'ug_group' => $groupsWithBotPermission, + 'ug_user = ' . $imgQuery['fields']['img_user'], + 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() ) + ] + ]; + } + } + + if ( $opts->getValue( 'hidepatrolled' ) ) { + global $wgActorTableSchemaMigrationStage; + + $tables[] = 'recentchanges'; + $conds['rc_type'] = RC_LOG; + $conds['rc_log_type'] = 'upload'; + $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED; + $conds['rc_namespace'] = NS_FILE; + + if ( $wgActorTableSchemaMigrationStage === MIGRATION_NEW ) { + $jcond = 'rc_actor = ' . $imgQuery['fields']['img_actor']; + } else { + $rcQuery = ActorMigration::newMigration()->getJoin( 'rc_user' ); + $tables += $rcQuery['tables']; + $jconds += $rcQuery['joins']; + $jcond = $rcQuery['fields']['rc_user'] . ' = ' . $imgQuery['fields']['img_user']; + } + $jconds['recentchanges'] = [ + 'INNER JOIN', + [ + 'rc_title = img_name', + $jcond, + 'rc_timestamp = img_timestamp' + ] + ]; + // We're ordering by img_timestamp, so we have to make sure MariaDB queries `image` first. + // It sometimes decides to query `recentchanges` first and filesort the result set later + // to get the right ordering. T124205 / https://mariadb.atlassian.net/browse/MDEV-8880 + $options[] = 'STRAIGHT_JOIN'; + } + + if ( $opts->getValue( 'mediatype' ) ) { + $conds['img_media_type'] = $opts->getValue( 'mediatype' ); + } + + $likeVal = $opts->getValue( 'like' ); + if ( !$this->getConfig()->get( 'MiserMode' ) && $likeVal !== '' ) { + $dbr = wfGetDB( DB_REPLICA ); + $likeObj = Title::newFromText( $likeVal ); + if ( $likeObj instanceof Title ) { + $like = $dbr->buildLike( + $dbr->anyString(), + strtolower( $likeObj->getDBkey() ), + $dbr->anyString() + ); + $conds[] = "LOWER(img_name) $like"; + } + } + + $query = [ + 'tables' => $tables, + 'fields' => $fields, + 'join_conds' => $jconds, + 'conds' => $conds, + 'options' => $options, + ]; + + return $query; + } + + function getIndexField() { + return 'img_timestamp'; + } + + function getStartBody() { + if ( !$this->gallery ) { + // Note that null for mode is taken to mean use default. + $mode = $this->getRequest()->getVal( 'gallerymode', null ); + try { + $this->gallery = ImageGalleryBase::factory( $mode, $this->getContext() ); + } catch ( Exception $e ) { + // User specified something invalid, fallback to default. + $this->gallery = ImageGalleryBase::factory( false, $this->getContext() ); + } + } + + return ''; + } + + function getEndBody() { + return $this->gallery->toHTML(); + } + + function formatRow( $row ) { + $name = $row->img_name; + $user = User::newFromId( $row->img_user ); + + $title = Title::makeTitle( NS_FILE, $name ); + $ul = MediaWikiServices::getInstance()->getLinkRenderer()->makeLink( + $user->getUserPage(), + $user->getName() + ); + $time = $this->getLanguage()->userTimeAndDate( $row->img_timestamp, $this->getUser() ); + + $this->gallery->add( + $title, + "$ul<br />\n<i>" + . htmlspecialchars( $time ) + . "</i><br />\n" + ); + } +} diff --git a/www/wiki/includes/specials/pagers/NewPagesPager.php b/www/wiki/includes/specials/pagers/NewPagesPager.php new file mode 100644 index 00000000..f16a5cb6 --- /dev/null +++ b/www/wiki/includes/specials/pagers/NewPagesPager.php @@ -0,0 +1,159 @@ +<?php +/** + * 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 Pager + */ + +/** + * @ingroup Pager + */ +class NewPagesPager extends ReverseChronologicalPager { + + // Stored opts + protected $opts; + + /** + * @var HTMLForm + */ + protected $mForm; + + function __construct( $form, FormOptions $opts ) { + parent::__construct( $form->getContext() ); + $this->mForm = $form; + $this->opts = $opts; + } + + function getQueryInfo() { + $rcQuery = RecentChange::getQueryInfo(); + + $conds = []; + $conds['rc_new'] = 1; + + $namespace = $this->opts->getValue( 'namespace' ); + $namespace = ( $namespace === 'all' ) ? false : intval( $namespace ); + + $username = $this->opts->getValue( 'username' ); + $user = Title::makeTitleSafe( NS_USER, $username ); + + $size = abs( intval( $this->opts->getValue( 'size' ) ) ); + if ( $size > 0 ) { + if ( $this->opts->getValue( 'size-mode' ) === 'max' ) { + $conds[] = 'page_len <= ' . $size; + } else { + $conds[] = 'page_len >= ' . $size; + } + } + + $rcIndexes = []; + + if ( $namespace !== false ) { + if ( $this->opts->getValue( 'invert' ) ) { + $conds[] = 'rc_namespace != ' . $this->mDb->addQuotes( $namespace ); + } else { + $conds['rc_namespace'] = $namespace; + } + } + + if ( $user ) { + $conds[] = ActorMigration::newMigration()->getWhere( + $this->mDb, 'rc_user', User::newFromName( $user->getText(), false ), false + )['conds']; + } elseif ( User::groupHasPermission( '*', 'createpage' ) && + $this->opts->getValue( 'hideliu' ) + ) { + # If anons cannot make new pages, don't "exclude logged in users"! + $conds[] = ActorMigration::newMigration()->isAnon( $rcQuery['fields']['rc_user'] ); + } + + # If this user cannot see patrolled edits or they are off, don't do dumb queries! + if ( $this->opts->getValue( 'hidepatrolled' ) && $this->getUser()->useNPPatrol() ) { + $conds['rc_patrolled'] = RecentChange::PRC_UNPATROLLED; + } + + if ( $this->opts->getValue( 'hidebots' ) ) { + $conds['rc_bot'] = 0; + } + + if ( $this->opts->getValue( 'hideredirs' ) ) { + $conds['page_is_redirect'] = 0; + } + + // Allow changes to the New Pages query + $tables = array_merge( $rcQuery['tables'], [ 'page' ] ); + $fields = array_merge( $rcQuery['fields'], [ + 'length' => 'page_len', 'rev_id' => 'page_latest', 'page_namespace', 'page_title' + ] ); + $join_conds = [ 'page' => [ 'INNER JOIN', 'page_id=rc_cur_id' ] ] + $rcQuery['joins']; + + // Avoid PHP 7.1 warning from passing $this by reference + $pager = $this; + Hooks::run( 'SpecialNewpagesConditions', + [ &$pager, $this->opts, &$conds, &$tables, &$fields, &$join_conds ] ); + + $options = []; + + if ( $rcIndexes ) { + $options = [ 'USE INDEX' => [ 'recentchanges' => $rcIndexes ] ]; + } + + $info = [ + 'tables' => $tables, + 'fields' => $fields, + 'conds' => $conds, + 'options' => $options, + 'join_conds' => $join_conds + ]; + + // Modify query for tags + ChangeTags::modifyDisplayQuery( + $info['tables'], + $info['fields'], + $info['conds'], + $info['join_conds'], + $info['options'], + $this->opts['tagfilter'] + ); + + return $info; + } + + function getIndexField() { + return 'rc_timestamp'; + } + + function formatRow( $row ) { + return $this->mForm->formatRow( $row ); + } + + function getStartBody() { + # Do a batch existence check on pages + $linkBatch = new LinkBatch(); + foreach ( $this->mResult as $row ) { + $linkBatch->add( NS_USER, $row->rc_user_text ); + $linkBatch->add( NS_USER_TALK, $row->rc_user_text ); + $linkBatch->add( $row->page_namespace, $row->page_title ); + } + $linkBatch->execute(); + + return '<ul>'; + } + + function getEndBody() { + return '</ul>'; + } +} diff --git a/www/wiki/includes/specials/pagers/ProtectedPagesPager.php b/www/wiki/includes/specials/pagers/ProtectedPagesPager.php new file mode 100644 index 00000000..3b69698f --- /dev/null +++ b/www/wiki/includes/specials/pagers/ProtectedPagesPager.php @@ -0,0 +1,338 @@ +<?php +/** + * 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 Pager + */ + +use MediaWiki\Linker\LinkRenderer; + +class ProtectedPagesPager extends TablePager { + + public $mForm, $mConds; + private $type, $level, $namespace, $sizetype, $size, $indefonly, $cascadeonly, $noredirect; + + /** + * @var LinkRenderer + */ + private $linkRenderer; + + /** + * @param SpecialProtectedpages $form + * @param array $conds + * @param string $type + * @param string $level + * @param int $namespace + * @param string $sizetype + * @param int $size + * @param bool $indefonly + * @param bool $cascadeonly + * @param bool $noredirect + * @param LinkRenderer $linkRenderer + */ + function __construct( $form, $conds = [], $type, $level, $namespace, + $sizetype = '', $size = 0, $indefonly = false, $cascadeonly = false, $noredirect = false, + LinkRenderer $linkRenderer + ) { + $this->mForm = $form; + $this->mConds = $conds; + $this->type = ( $type ) ? $type : 'edit'; + $this->level = $level; + $this->namespace = $namespace; + $this->sizetype = $sizetype; + $this->size = intval( $size ); + $this->indefonly = (bool)$indefonly; + $this->cascadeonly = (bool)$cascadeonly; + $this->noredirect = (bool)$noredirect; + $this->linkRenderer = $linkRenderer; + parent::__construct( $form->getContext() ); + } + + function preprocessResults( $result ) { + # Do a link batch query + $lb = new LinkBatch; + $userids = []; + + foreach ( $result as $row ) { + $lb->add( $row->page_namespace, $row->page_title ); + // field is nullable, maybe null on old protections + if ( $row->log_user !== null ) { + $userids[] = $row->log_user; + } + } + + // fill LinkBatch with user page and user talk + if ( count( $userids ) ) { + $userCache = UserCache::singleton(); + $userCache->doQuery( $userids, [], __METHOD__ ); + foreach ( $userids as $userid ) { + $name = $userCache->getProp( $userid, 'name' ); + if ( $name !== false ) { + $lb->add( NS_USER, $name ); + $lb->add( NS_USER_TALK, $name ); + } + } + } + + $lb->execute(); + } + + function getFieldNames() { + static $headers = null; + + if ( $headers == [] ) { + $headers = [ + 'log_timestamp' => 'protectedpages-timestamp', + 'pr_page' => 'protectedpages-page', + 'pr_expiry' => 'protectedpages-expiry', + 'log_user' => 'protectedpages-performer', + 'pr_params' => 'protectedpages-params', + 'log_comment' => 'protectedpages-reason', + ]; + foreach ( $headers as $key => $val ) { + $headers[$key] = $this->msg( $val )->text(); + } + } + + return $headers; + } + + /** + * @param string $field + * @param string $value + * @return string HTML + * @throws MWException + */ + function formatValue( $field, $value ) { + /** @var object $row */ + $row = $this->mCurrentRow; + + switch ( $field ) { + case 'log_timestamp': + // when timestamp is null, this is a old protection row + if ( $value === null ) { + $formatted = Html::rawElement( + 'span', + [ 'class' => 'mw-protectedpages-unknown' ], + $this->msg( 'protectedpages-unknown-timestamp' )->escaped() + ); + } else { + $formatted = htmlspecialchars( $this->getLanguage()->userTimeAndDate( + $value, $this->getUser() ) ); + } + break; + + case 'pr_page': + $title = Title::makeTitleSafe( $row->page_namespace, $row->page_title ); + if ( !$title ) { + $formatted = Html::element( + 'span', + [ 'class' => 'mw-invalidtitle' ], + Linker::getInvalidTitleDescription( + $this->getContext(), + $row->page_namespace, + $row->page_title + ) + ); + } else { + $formatted = $this->linkRenderer->makeLink( $title ); + } + if ( !is_null( $row->page_len ) ) { + $formatted .= $this->getLanguage()->getDirMark() . + ' ' . Html::rawElement( + 'span', + [ 'class' => 'mw-protectedpages-length' ], + Linker::formatRevisionSize( $row->page_len ) + ); + } + break; + + case 'pr_expiry': + $formatted = htmlspecialchars( $this->getLanguage()->formatExpiry( + $value, /* User preference timezone */true ) ); + $title = Title::makeTitleSafe( $row->page_namespace, $row->page_title ); + if ( $this->getUser()->isAllowed( 'protect' ) && $title ) { + $changeProtection = $this->linkRenderer->makeKnownLink( + $title, + $this->msg( 'protect_change' )->text(), + [], + [ 'action' => 'unprotect' ] + ); + $formatted .= ' ' . Html::rawElement( + 'span', + [ 'class' => 'mw-protectedpages-actions' ], + $this->msg( 'parentheses' )->rawParams( $changeProtection )->escaped() + ); + } + break; + + case 'log_user': + // when timestamp is null, this is a old protection row + if ( $row->log_timestamp === null ) { + $formatted = Html::rawElement( + 'span', + [ 'class' => 'mw-protectedpages-unknown' ], + $this->msg( 'protectedpages-unknown-performer' )->escaped() + ); + } else { + $username = UserCache::singleton()->getProp( $value, 'name' ); + if ( LogEventsList::userCanBitfield( + $row->log_deleted, + LogPage::DELETED_USER, + $this->getUser() + ) ) { + if ( $username === false ) { + $formatted = htmlspecialchars( $value ); + } else { + $formatted = Linker::userLink( $value, $username ) + . Linker::userToolLinks( $value, $username ); + } + } else { + $formatted = $this->msg( 'rev-deleted-user' )->escaped(); + } + if ( LogEventsList::isDeleted( $row, LogPage::DELETED_USER ) ) { + $formatted = '<span class="history-deleted">' . $formatted . '</span>'; + } + } + break; + + case 'pr_params': + $params = []; + // Messages: restriction-level-sysop, restriction-level-autoconfirmed + $params[] = $this->msg( 'restriction-level-' . $row->pr_level )->escaped(); + if ( $row->pr_cascade ) { + $params[] = $this->msg( 'protect-summary-cascade' )->escaped(); + } + $formatted = $this->getLanguage()->commaList( $params ); + break; + + case 'log_comment': + // when timestamp is null, this is an old protection row + if ( $row->log_timestamp === null ) { + $formatted = Html::rawElement( + 'span', + [ 'class' => 'mw-protectedpages-unknown' ], + $this->msg( 'protectedpages-unknown-reason' )->escaped() + ); + } else { + if ( LogEventsList::userCanBitfield( + $row->log_deleted, + LogPage::DELETED_COMMENT, + $this->getUser() + ) ) { + $value = CommentStore::getStore()->getComment( 'log_comment', $row )->text; + $formatted = Linker::formatComment( $value !== null ? $value : '' ); + } else { + $formatted = $this->msg( 'rev-deleted-comment' )->escaped(); + } + if ( LogEventsList::isDeleted( $row, LogPage::DELETED_COMMENT ) ) { + $formatted = '<span class="history-deleted">' . $formatted . '</span>'; + } + } + break; + + default: + throw new MWException( "Unknown field '$field'" ); + } + + return $formatted; + } + + function getQueryInfo() { + $conds = $this->mConds; + $conds[] = 'pr_expiry > ' . $this->mDb->addQuotes( $this->mDb->timestamp() ) . + ' OR pr_expiry IS NULL'; + $conds[] = 'page_id=pr_page'; + $conds[] = 'pr_type=' . $this->mDb->addQuotes( $this->type ); + + if ( $this->sizetype == 'min' ) { + $conds[] = 'page_len>=' . $this->size; + } elseif ( $this->sizetype == 'max' ) { + $conds[] = 'page_len<=' . $this->size; + } + + if ( $this->indefonly ) { + $infinity = $this->mDb->addQuotes( $this->mDb->getInfinity() ); + $conds[] = "pr_expiry = $infinity OR pr_expiry IS NULL"; + } + if ( $this->cascadeonly ) { + $conds[] = 'pr_cascade = 1'; + } + if ( $this->noredirect ) { + $conds[] = 'page_is_redirect = 0'; + } + + if ( $this->level ) { + $conds[] = 'pr_level=' . $this->mDb->addQuotes( $this->level ); + } + if ( !is_null( $this->namespace ) ) { + $conds[] = 'page_namespace=' . $this->mDb->addQuotes( $this->namespace ); + } + + $commentQuery = CommentStore::getStore()->getJoin( 'log_comment' ); + $actorQuery = ActorMigration::newMigration()->getJoin( 'log_user' ); + + return [ + 'tables' => [ + 'page', 'page_restrictions', 'log_search', + 'logparen' => [ 'logging' ] + $commentQuery['tables'] + $actorQuery['tables'], + ], + 'fields' => [ + 'pr_id', + 'page_namespace', + 'page_title', + 'page_len', + 'pr_type', + 'pr_level', + 'pr_expiry', + 'pr_cascade', + 'log_timestamp', + 'log_deleted', + ] + $commentQuery['fields'] + $actorQuery['fields'], + 'conds' => $conds, + 'join_conds' => [ + 'log_search' => [ + 'LEFT JOIN', [ + 'ls_field' => 'pr_id', 'ls_value = ' . $this->mDb->buildStringCast( 'pr_id' ) + ] + ], + 'logparen' => [ + 'LEFT JOIN', [ + 'ls_log_id = log_id' + ] + ] + ] + $commentQuery['joins'] + $actorQuery['joins'] + ]; + } + + protected function getTableClass() { + return parent::getTableClass() . ' mw-protectedpages'; + } + + function getIndexField() { + return 'pr_id'; + } + + function getDefaultSort() { + return 'pr_id'; + } + + function isFieldSortable( $field ) { + // no index for sorting exists + return false; + } +} diff --git a/www/wiki/includes/specials/pagers/ProtectedTitlesPager.php b/www/wiki/includes/specials/pagers/ProtectedTitlesPager.php new file mode 100644 index 00000000..8f172f8b --- /dev/null +++ b/www/wiki/includes/specials/pagers/ProtectedTitlesPager.php @@ -0,0 +1,91 @@ +<?php +/** + * 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 Pager + */ + +/** + * @ingroup Pager + */ +class ProtectedTitlesPager extends AlphabeticPager { + + public $mForm, $mConds; + + function __construct( $form, $conds = [], $type, $level, $namespace, + $sizetype = '', $size = 0 + ) { + $this->mForm = $form; + $this->mConds = $conds; + $this->level = $level; + $this->namespace = $namespace; + $this->size = intval( $size ); + parent::__construct( $form->getContext() ); + } + + function getStartBody() { + # Do a link batch query + $this->mResult->seek( 0 ); + $lb = new LinkBatch; + + foreach ( $this->mResult as $row ) { + $lb->add( $row->pt_namespace, $row->pt_title ); + } + + $lb->execute(); + + return ''; + } + + /** + * @return Title + */ + function getTitle() { + return $this->mForm->getTitle(); + } + + function formatRow( $row ) { + return $this->mForm->formatRow( $row ); + } + + /** + * @return array + */ + function getQueryInfo() { + $conds = $this->mConds; + $conds[] = 'pt_expiry > ' . $this->mDb->addQuotes( $this->mDb->timestamp() ) . + ' OR pt_expiry IS NULL'; + if ( $this->level ) { + $conds['pt_create_perm'] = $this->level; + } + + if ( !is_null( $this->namespace ) ) { + $conds[] = 'pt_namespace=' . $this->mDb->addQuotes( $this->namespace ); + } + + return [ + 'tables' => 'protected_titles', + 'fields' => [ 'pt_namespace', 'pt_title', 'pt_create_perm', + 'pt_expiry', 'pt_timestamp' ], + 'conds' => $conds + ]; + } + + function getIndexField() { + return 'pt_timestamp'; + } +} diff --git a/www/wiki/includes/specials/pagers/UsersPager.php b/www/wiki/includes/specials/pagers/UsersPager.php new file mode 100644 index 00000000..3b9f9a17 --- /dev/null +++ b/www/wiki/includes/specials/pagers/UsersPager.php @@ -0,0 +1,416 @@ +<?php +/** + * Copyright © 2004 Brion Vibber, lcrocker, Tim Starling, + * Domas Mituzas, Antoine Musso, Jens Frank, Zhengzhu, + * 2006 Rob Church <robchur@gmail.com> + * + * 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 Pager + */ + +/** + * This class is used to get a list of user. The ones with specials + * rights (sysop, bureaucrat, developer) will have them displayed + * next to their names. + * + * @ingroup Pager + */ +class UsersPager extends AlphabeticPager { + + /** + * @var array[] A array with user ids as key and a array of groups as value + */ + protected $userGroupCache; + + /** + * @param IContextSource $context + * @param array $par (Default null) + * @param bool $including Whether this page is being transcluded in + * another page + */ + function __construct( IContextSource $context = null, $par = null, $including = null ) { + if ( $context ) { + $this->setContext( $context ); + } + + $request = $this->getRequest(); + $par = ( $par !== null ) ? $par : ''; + $parms = explode( '/', $par ); + $symsForAll = [ '*', 'user' ]; + + if ( $parms[0] != '' && + ( in_array( $par, User::getAllGroups() ) || in_array( $par, $symsForAll ) ) + ) { + $this->requestedGroup = $par; + $un = $request->getText( 'username' ); + } elseif ( count( $parms ) == 2 ) { + $this->requestedGroup = $parms[0]; + $un = $parms[1]; + } else { + $this->requestedGroup = $request->getVal( 'group' ); + $un = ( $par != '' ) ? $par : $request->getText( 'username' ); + } + + if ( in_array( $this->requestedGroup, $symsForAll ) ) { + $this->requestedGroup = ''; + } + $this->editsOnly = $request->getBool( 'editsOnly' ); + $this->creationSort = $request->getBool( 'creationSort' ); + $this->including = $including; + $this->mDefaultDirection = $request->getBool( 'desc' ) + ? IndexPager::DIR_DESCENDING + : IndexPager::DIR_ASCENDING; + + $this->requestedUser = ''; + + if ( $un != '' ) { + $username = Title::makeTitleSafe( NS_USER, $un ); + + if ( !is_null( $username ) ) { + $this->requestedUser = $username->getText(); + } + } + + parent::__construct(); + } + + /** + * @return string + */ + function getIndexField() { + return $this->creationSort ? 'user_id' : 'user_name'; + } + + /** + * @return array + */ + function getQueryInfo() { + $dbr = wfGetDB( DB_REPLICA ); + $conds = []; + + // Don't show hidden names + if ( !$this->getUser()->isAllowed( 'hideuser' ) ) { + $conds[] = 'ipb_deleted IS NULL OR ipb_deleted = 0'; + } + + $options = []; + + if ( $this->requestedGroup != '' ) { + $conds['ug_group'] = $this->requestedGroup; + $conds[] = 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() ); + } + + if ( $this->requestedUser != '' ) { + # Sorted either by account creation or name + if ( $this->creationSort ) { + $conds[] = 'user_id >= ' . intval( User::idFromName( $this->requestedUser ) ); + } else { + $conds[] = 'user_name >= ' . $dbr->addQuotes( $this->requestedUser ); + } + } + + if ( $this->editsOnly ) { + $conds[] = 'user_editcount > 0'; + } + + $options['GROUP BY'] = $this->creationSort ? 'user_id' : 'user_name'; + + $query = [ + 'tables' => [ 'user', 'user_groups', 'ipblocks' ], + 'fields' => [ + 'user_name' => $this->creationSort ? 'MAX(user_name)' : 'user_name', + 'user_id' => $this->creationSort ? 'user_id' : 'MAX(user_id)', + 'edits' => 'MAX(user_editcount)', + 'creation' => 'MIN(user_registration)', + 'ipb_deleted' => 'MAX(ipb_deleted)' // block/hide status + ], + 'options' => $options, + 'join_conds' => [ + 'user_groups' => [ 'LEFT JOIN', 'user_id=ug_user' ], + 'ipblocks' => [ + 'LEFT JOIN', [ + 'user_id=ipb_user', + 'ipb_auto' => 0 + ] + ], + ], + 'conds' => $conds + ]; + + Hooks::run( 'SpecialListusersQueryInfo', [ $this, &$query ] ); + + return $query; + } + + /** + * @param stdClass $row + * @return string + */ + function formatRow( $row ) { + if ( $row->user_id == 0 ) { # T18487 + return ''; + } + + $userName = $row->user_name; + + $ulinks = Linker::userLink( $row->user_id, $userName ); + $ulinks .= Linker::userToolLinksRedContribs( + $row->user_id, + $userName, + (int)$row->edits + ); + + $lang = $this->getLanguage(); + + $groups = ''; + $ugms = self::getGroupMemberships( intval( $row->user_id ), $this->userGroupCache ); + + if ( !$this->including && count( $ugms ) > 0 ) { + $list = []; + foreach ( $ugms as $ugm ) { + $list[] = $this->buildGroupLink( $ugm, $userName ); + } + $groups = $lang->commaList( $list ); + } + + $item = $lang->specialList( $ulinks, $groups ); + + if ( $row->ipb_deleted ) { + $item = "<span class=\"deleted\">$item</span>"; + } + + $edits = ''; + if ( !$this->including && $this->getConfig()->get( 'Edititis' ) ) { + $count = $this->msg( 'usereditcount' )->numParams( $row->edits )->escaped(); + $edits = $this->msg( 'word-separator' )->escaped() . $this->msg( 'brackets', $count )->escaped(); + } + + $created = ''; + # Some rows may be null + if ( !$this->including && $row->creation ) { + $user = $this->getUser(); + $d = $lang->userDate( $row->creation, $user ); + $t = $lang->userTime( $row->creation, $user ); + $created = $this->msg( 'usercreated', $d, $t, $row->user_name )->escaped(); + $created = ' ' . $this->msg( 'parentheses' )->rawParams( $created )->escaped(); + } + $blocked = !is_null( $row->ipb_deleted ) ? + ' ' . $this->msg( 'listusers-blocked', $userName )->escaped() : + ''; + + Hooks::run( 'SpecialListusersFormatRow', [ &$item, $row ] ); + + return Html::rawElement( 'li', [], "{$item}{$edits}{$created}{$blocked}" ); + } + + function doBatchLookups() { + $batch = new LinkBatch(); + $userIds = []; + # Give some pointers to make user links + foreach ( $this->mResult as $row ) { + $batch->add( NS_USER, $row->user_name ); + $batch->add( NS_USER_TALK, $row->user_name ); + $userIds[] = $row->user_id; + } + + // Lookup groups for all the users + $dbr = wfGetDB( DB_REPLICA ); + $groupRes = $dbr->select( + 'user_groups', + UserGroupMembership::selectFields(), + [ 'ug_user' => $userIds ], + __METHOD__ + ); + $cache = []; + $groups = []; + foreach ( $groupRes as $row ) { + $ugm = UserGroupMembership::newFromRow( $row ); + if ( !$ugm->isExpired() ) { + $cache[$row->ug_user][$row->ug_group] = $ugm; + $groups[$row->ug_group] = true; + } + } + + // Give extensions a chance to add things like global user group data + // into the cache array to ensure proper output later on + Hooks::run( 'UsersPagerDoBatchLookups', [ $dbr, $userIds, &$cache, &$groups ] ); + + $this->userGroupCache = $cache; + + // Add page of groups to link batch + foreach ( $groups as $group => $unused ) { + $groupPage = UserGroupMembership::getGroupPage( $group ); + if ( $groupPage ) { + $batch->addObj( $groupPage ); + } + } + + $batch->execute(); + $this->mResult->rewind(); + } + + /** + * @return string + */ + function getPageHeader() { + list( $self ) = explode( '/', $this->getTitle()->getPrefixedDBkey() ); + + $groupOptions = [ $this->msg( 'group-all' )->text() => '' ]; + foreach ( $this->getAllGroups() as $group => $groupText ) { + $groupOptions[ $groupText ] = $group; + } + + $formDescriptor = [ + 'user' => [ + 'class' => HTMLUserTextField::class, + 'label' => $this->msg( 'listusersfrom' )->text(), + 'name' => 'username', + 'default' => $this->requestedUser, + ], + 'dropdown' => [ + 'label' => $this->msg( 'group' )->text(), + 'name' => 'group', + 'default' => $this->requestedGroup, + 'class' => HTMLSelectField::class, + 'options' => $groupOptions, + ], + 'editsOnly' => [ + 'type' => 'check', + 'label' => $this->msg( 'listusers-editsonly' )->text(), + 'name' => 'editsOnly', + 'id' => 'editsOnly', + 'default' => $this->editsOnly + ], + 'creationSort' => [ + 'type' => 'check', + 'label' => $this->msg( 'listusers-creationsort' )->text(), + 'name' => 'creationSort', + 'id' => 'creationSort', + 'default' => $this->creationSort + ], + 'desc' => [ + 'type' => 'check', + 'label' => $this->msg( 'listusers-desc' )->text(), + 'name' => 'desc', + 'id' => 'desc', + 'default' => $this->mDefaultDirection + ], + 'limithiddenfield' => [ + 'class' => HTMLHiddenField::class, + 'name' => 'limit', + 'default' => $this->mLimit + ] + ]; + + $beforeSubmitButtonHookOut = ''; + Hooks::run( 'SpecialListusersHeaderForm', [ $this, &$beforeSubmitButtonHookOut ] ); + + if ( $beforeSubmitButtonHookOut !== '' ) { + $formDescriptor[ 'beforeSubmitButtonHookOut' ] = [ + 'class' => HTMLInfoField::class, + 'raw' => true, + 'default' => $beforeSubmitButtonHookOut + ]; + } + + $formDescriptor[ 'submit' ] = [ + 'class' => HTMLSubmitField::class, + 'buttonlabel-message' => 'listusers-submit', + ]; + + $beforeClosingFieldsetHookOut = ''; + Hooks::run( 'SpecialListusersHeader', [ $this, &$beforeClosingFieldsetHookOut ] ); + + if ( $beforeClosingFieldsetHookOut !== '' ) { + $formDescriptor[ 'beforeClosingFieldsetHookOut' ] = [ + 'class' => HTMLInfoField::class, + 'raw' => true, + 'default' => $beforeClosingFieldsetHookOut + ]; + } + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() ); + $htmlForm + ->setMethod( 'get' ) + ->setAction( Title::newFromText( $self )->getLocalURL() ) + ->setId( 'mw-listusers-form' ) + ->setFormIdentifier( 'mw-listusers-form' ) + ->suppressDefaultSubmit() + ->setWrapperLegendMsg( 'listusers' ); + return $htmlForm->prepareForm()->getHTML( true ); + } + + /** + * Get a list of all explicit groups + * @return array + */ + function getAllGroups() { + $result = []; + foreach ( User::getAllGroups() as $group ) { + $result[$group] = UserGroupMembership::getGroupName( $group ); + } + asort( $result ); + + return $result; + } + + /** + * Preserve group and username offset parameters when paging + * @return array + */ + function getDefaultQuery() { + $query = parent::getDefaultQuery(); + if ( $this->requestedGroup != '' ) { + $query['group'] = $this->requestedGroup; + } + if ( $this->requestedUser != '' ) { + $query['username'] = $this->requestedUser; + } + Hooks::run( 'SpecialListusersDefaultQuery', [ $this, &$query ] ); + + return $query; + } + + /** + * Get an associative array containing groups the specified user belongs to, + * and the relevant UserGroupMembership objects + * + * @param int $uid User id + * @param array[]|null $cache + * @return UserGroupMembership[] (group name => UserGroupMembership object) + */ + protected static function getGroupMemberships( $uid, $cache = null ) { + if ( $cache === null ) { + $user = User::newFromId( $uid ); + return $user->getGroupMemberships(); + } else { + return isset( $cache[$uid] ) ? $cache[$uid] : []; + } + } + + /** + * Format a link to a group description page + * + * @param string|UserGroupMembership $group Group name or UserGroupMembership object + * @param string $username + * @return string + */ + protected function buildGroupLink( $group, $username ) { + return UserGroupMembership::getLink( $group, $this->getContext(), 'html', $username ); + } +} |