summaryrefslogtreecommitdiff
path: root/www/wiki/includes/specials
diff options
context:
space:
mode:
authorYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
committerYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
commitfc7369835258467bf97eb64f184b93691f9a9fd5 (patch)
treedaabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/includes/specials
first commit
Diffstat (limited to 'www/wiki/includes/specials')
-rw-r--r--www/wiki/includes/specials/SpecialActiveusers.php172
-rw-r--r--www/wiki/includes/specials/SpecialAllMessages.php74
-rw-r--r--www/wiki/includes/specials/SpecialAllPages.php384
-rw-r--r--www/wiki/includes/specials/SpecialAncientpages.php93
-rw-r--r--www/wiki/includes/specials/SpecialApiHelp.php98
-rw-r--r--www/wiki/includes/specials/SpecialApiSandbox.php59
-rw-r--r--www/wiki/includes/specials/SpecialAutoblockList.php167
-rw-r--r--www/wiki/includes/specials/SpecialBlankpage.php39
-rw-r--r--www/wiki/includes/specials/SpecialBlock.php1038
-rw-r--r--www/wiki/includes/specials/SpecialBlockList.php225
-rw-r--r--www/wiki/includes/specials/SpecialBooksources.php214
-rw-r--r--www/wiki/includes/specials/SpecialBotPasswords.php367
-rw-r--r--www/wiki/includes/specials/SpecialBrokenRedirects.php179
-rw-r--r--www/wiki/includes/specials/SpecialCachedPage.php201
-rw-r--r--www/wiki/includes/specials/SpecialCategories.php65
-rw-r--r--www/wiki/includes/specials/SpecialChangeContentModel.php296
-rw-r--r--www/wiki/includes/specials/SpecialChangeCredentials.php267
-rw-r--r--www/wiki/includes/specials/SpecialChangeEmail.php206
-rw-r--r--www/wiki/includes/specials/SpecialChangePassword.php36
-rw-r--r--www/wiki/includes/specials/SpecialComparePages.php174
-rw-r--r--www/wiki/includes/specials/SpecialConfirmemail.php168
-rw-r--r--www/wiki/includes/specials/SpecialContributions.php780
-rw-r--r--www/wiki/includes/specials/SpecialCreateAccount.php173
-rw-r--r--www/wiki/includes/specials/SpecialDeadendpages.php94
-rw-r--r--www/wiki/includes/specials/SpecialDeletedContributions.php243
-rw-r--r--www/wiki/includes/specials/SpecialDiff.php119
-rw-r--r--www/wiki/includes/specials/SpecialDoubleRedirects.php233
-rw-r--r--www/wiki/includes/specials/SpecialEditTags.php473
-rw-r--r--www/wiki/includes/specials/SpecialEditWatchlist.php767
-rw-r--r--www/wiki/includes/specials/SpecialEmailInvalidate.php75
-rw-r--r--www/wiki/includes/specials/SpecialEmailuser.php525
-rw-r--r--www/wiki/includes/specials/SpecialExpandTemplates.php301
-rw-r--r--www/wiki/includes/specials/SpecialExport.php593
-rw-r--r--www/wiki/includes/specials/SpecialFewestrevisions.php105
-rw-r--r--www/wiki/includes/specials/SpecialFileDuplicateSearch.php267
-rw-r--r--www/wiki/includes/specials/SpecialFilepath.php55
-rw-r--r--www/wiki/includes/specials/SpecialGoToInterwiki.php79
-rw-r--r--www/wiki/includes/specials/SpecialImport.php566
-rw-r--r--www/wiki/includes/specials/SpecialJavaScriptTest.php205
-rw-r--r--www/wiki/includes/specials/SpecialLinkAccounts.php111
-rw-r--r--www/wiki/includes/specials/SpecialLinkSearch.php274
-rw-r--r--www/wiki/includes/specials/SpecialListDuplicatedFiles.php106
-rw-r--r--www/wiki/includes/specials/SpecialListfiles.php83
-rw-r--r--www/wiki/includes/specials/SpecialListgrants.php91
-rw-r--r--www/wiki/includes/specials/SpecialListgrouprights.php294
-rw-r--r--www/wiki/includes/specials/SpecialListredirects.php151
-rw-r--r--www/wiki/includes/specials/SpecialListusers.php101
-rw-r--r--www/wiki/includes/specials/SpecialLockdb.php118
-rw-r--r--www/wiki/includes/specials/SpecialLog.php327
-rw-r--r--www/wiki/includes/specials/SpecialLonelypages.php102
-rw-r--r--www/wiki/includes/specials/SpecialLongpages.php40
-rw-r--r--www/wiki/includes/specials/SpecialMIMEsearch.php241
-rw-r--r--www/wiki/includes/specials/SpecialMediaStatistics.php371
-rw-r--r--www/wiki/includes/specials/SpecialMergeHistory.php385
-rw-r--r--www/wiki/includes/specials/SpecialMostcategories.php112
-rw-r--r--www/wiki/includes/specials/SpecialMostimages.php67
-rw-r--r--www/wiki/includes/specials/SpecialMostinterwikis.php115
-rw-r--r--www/wiki/includes/specials/SpecialMostlinked.php135
-rw-r--r--www/wiki/includes/specials/SpecialMostlinkedcategories.php98
-rw-r--r--www/wiki/includes/specials/SpecialMostlinkedtemplates.php132
-rw-r--r--www/wiki/includes/specials/SpecialMostrevisions.php39
-rw-r--r--www/wiki/includes/specials/SpecialMovepage.php872
-rw-r--r--www/wiki/includes/specials/SpecialMyLanguage.php138
-rw-r--r--www/wiki/includes/specials/SpecialMyRedirectPages.php185
-rw-r--r--www/wiki/includes/specials/SpecialNewimages.php230
-rw-r--r--www/wiki/includes/specials/SpecialNewpages.php518
-rw-r--r--www/wiki/includes/specials/SpecialPageData.php107
-rw-r--r--www/wiki/includes/specials/SpecialPageLanguage.php299
-rw-r--r--www/wiki/includes/specials/SpecialPagesWithProp.php240
-rw-r--r--www/wiki/includes/specials/SpecialPasswordReset.php173
-rw-r--r--www/wiki/includes/specials/SpecialPermanentLink.php82
-rw-r--r--www/wiki/includes/specials/SpecialPreferences.php173
-rw-r--r--www/wiki/includes/specials/SpecialPrefixindex.php319
-rw-r--r--www/wiki/includes/specials/SpecialProtectedpages.php207
-rw-r--r--www/wiki/includes/specials/SpecialProtectedtitles.php177
-rw-r--r--www/wiki/includes/specials/SpecialRandomInCategory.php315
-rw-r--r--www/wiki/includes/specials/SpecialRandompage.php180
-rw-r--r--www/wiki/includes/specials/SpecialRandomredirect.php35
-rw-r--r--www/wiki/includes/specials/SpecialRandomrootpage.php39
-rw-r--r--www/wiki/includes/specials/SpecialRecentchanges.php956
-rw-r--r--www/wiki/includes/specials/SpecialRecentchangeslinked.php314
-rw-r--r--www/wiki/includes/specials/SpecialRedirect.php326
-rw-r--r--www/wiki/includes/specials/SpecialRemoveCredentials.php26
-rw-r--r--www/wiki/includes/specials/SpecialResetTokens.php156
-rw-r--r--www/wiki/includes/specials/SpecialRevisiondelete.php689
-rw-r--r--www/wiki/includes/specials/SpecialRunJobs.php121
-rw-r--r--www/wiki/includes/specials/SpecialSearch.php718
-rw-r--r--www/wiki/includes/specials/SpecialShortpages.php178
-rw-r--r--www/wiki/includes/specials/SpecialSpecialpages.php158
-rw-r--r--www/wiki/includes/specials/SpecialStatistics.php307
-rw-r--r--www/wiki/includes/specials/SpecialTags.php482
-rw-r--r--www/wiki/includes/specials/SpecialTrackingCategories.php130
-rw-r--r--www/wiki/includes/specials/SpecialUnblock.php278
-rw-r--r--www/wiki/includes/specials/SpecialUncategorizedcategories.php93
-rw-r--r--www/wiki/includes/specials/SpecialUncategorizedimages.php65
-rw-r--r--www/wiki/includes/specials/SpecialUncategorizedpages.php85
-rw-r--r--www/wiki/includes/specials/SpecialUncategorizedtemplates.php36
-rw-r--r--www/wiki/includes/specials/SpecialUndelete.php1200
-rw-r--r--www/wiki/includes/specials/SpecialUnlinkAccounts.php79
-rw-r--r--www/wiki/includes/specials/SpecialUnlockdb.php96
-rw-r--r--www/wiki/includes/specials/SpecialUnusedcategories.php83
-rw-r--r--www/wiki/includes/specials/SpecialUnusedimages.php85
-rw-r--r--www/wiki/includes/specials/SpecialUnusedtemplates.php97
-rw-r--r--www/wiki/includes/specials/SpecialUnwatchedpages.php138
-rw-r--r--www/wiki/includes/specials/SpecialUpload.php853
-rw-r--r--www/wiki/includes/specials/SpecialUploadStash.php456
-rw-r--r--www/wiki/includes/specials/SpecialUserLogin.php162
-rw-r--r--www/wiki/includes/specials/SpecialUserLogout.php107
-rw-r--r--www/wiki/includes/specials/SpecialUserrights.php1042
-rw-r--r--www/wiki/includes/specials/SpecialVersion.php1201
-rw-r--r--www/wiki/includes/specials/SpecialWantedcategories.php131
-rw-r--r--www/wiki/includes/specials/SpecialWantedfiles.php153
-rw-r--r--www/wiki/includes/specials/SpecialWantedpages.php98
-rw-r--r--www/wiki/includes/specials/SpecialWantedtemplates.php61
-rw-r--r--www/wiki/includes/specials/SpecialWatchlist.php872
-rw-r--r--www/wiki/includes/specials/SpecialWhatlinkshere.php573
-rw-r--r--www/wiki/includes/specials/SpecialWithoutinterwiki.php110
-rw-r--r--www/wiki/includes/specials/formfields/EditWatchlistCheckboxSeriesField.php37
-rw-r--r--www/wiki/includes/specials/formfields/Licenses.php226
-rw-r--r--www/wiki/includes/specials/formfields/UploadSourceField.php68
-rw-r--r--www/wiki/includes/specials/forms/EditWatchlistNormalHTMLForm.php36
-rw-r--r--www/wiki/includes/specials/forms/PreferencesForm.php143
-rw-r--r--www/wiki/includes/specials/forms/UploadForm.php446
-rw-r--r--www/wiki/includes/specials/helpers/ImportReporter.php190
-rw-r--r--www/wiki/includes/specials/helpers/License.php61
-rw-r--r--www/wiki/includes/specials/helpers/LoginHelper.php98
-rw-r--r--www/wiki/includes/specials/pagers/ActiveUsersPager.php195
-rw-r--r--www/wiki/includes/specials/pagers/AllMessagesTablePager.php424
-rw-r--r--www/wiki/includes/specials/pagers/BlockListPager.php312
-rw-r--r--www/wiki/includes/specials/pagers/CategoryPager.php115
-rw-r--r--www/wiki/includes/specials/pagers/ContribsPager.php674
-rw-r--r--www/wiki/includes/specials/pagers/DeletedContribsPager.php365
-rw-r--r--www/wiki/includes/specials/pagers/ImageListPager.php628
-rw-r--r--www/wiki/includes/specials/pagers/MergeHistoryPager.php100
-rw-r--r--www/wiki/includes/specials/pagers/NewFilesPager.php208
-rw-r--r--www/wiki/includes/specials/pagers/NewPagesPager.php159
-rw-r--r--www/wiki/includes/specials/pagers/ProtectedPagesPager.php338
-rw-r--r--www/wiki/includes/specials/pagers/ProtectedTitlesPager.php91
-rw-r--r--www/wiki/includes/specials/pagers/UsersPager.php416
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( '&#160;', $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',
+ ''
+ ) . '&#160;' .
+ Html::namespaceSelector(
+ [ 'selected' => $this->opts['namespace'], 'all' => '' ],
+ [
+ 'name' => 'namespace',
+ 'id' => 'namespace',
+ 'class' => 'namespaceselector',
+ ]
+ ) . '&#160;' .
+ 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'
+ ]
+ ) . '&#160;'
+ ) .
+ 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'
+ ]
+ ) . '&#160;'
+ )
+ );
+
+ $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'
+ ) . '&#160;' .
+ 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(
+ [ '_&lt;nowiki&gt;_', '_&lt;/nowiki&gt;_', '_&lt;nowiki */&gt;_' ],
+ '',
+ $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>&#160;</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'], '&amp;' ) !== 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 = '&ndash;';
+ $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' ] ) . '&#160;';
+ }
+ } );
+ }
+ $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 .= '&#160;' .
+ 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( /* &nbsp */ "\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 = '&#160;';
+ }
+
+ $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 );
+ }
+}