Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.98% covered (success)
92.98%
503 / 541
60.00% covered (warning)
60.00%
9 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContributionsSpecialPage
92.98% covered (success)
92.98%
503 / 541
60.00% covered (warning)
60.00%
9 / 15
120.66
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 execute
90.18% covered (success)
90.18%
147 / 163
0.00% covered (danger)
0.00%
0 / 1
53.46
 contributionsSub
94.90% covered (success)
94.90%
93 / 98
0.00% covered (danger)
0.00%
0 / 1
26.09
 getUserLinks
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
1 / 1
14
 getTargetField
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 getForm
91.36% covered (success)
91.36%
148 / 162
0.00% covered (danger)
0.00%
0 / 1
13.11
 modifyFields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prefixSearchSubpages
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 providesFeeds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isArchive
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPager
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFormWrapperLegendMessageKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getResultsPageTitleMessageKey
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 shouldShowBlockLogExtract
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Implements Special:Contributions
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup SpecialPage
22 */
23
24namespace MediaWiki\SpecialPage;
25
26use LogEventsList;
27use MediaWiki\Block\Block;
28use MediaWiki\Block\DatabaseBlockStore;
29use MediaWiki\Html\Html;
30use MediaWiki\HTMLForm\Field\HTMLMultiSelectField;
31use MediaWiki\HTMLForm\HTMLForm;
32use MediaWiki\Pager\ContribsPager;
33use MediaWiki\Pager\ContributionsPager;
34use MediaWiki\Permissions\PermissionManager;
35use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
36use MediaWiki\Specials\SpecialUserRights;
37use MediaWiki\Status\Status;
38use MediaWiki\Title\NamespaceInfo;
39use MediaWiki\Title\Title;
40use MediaWiki\User\ExternalUserNames;
41use MediaWiki\User\Options\UserOptionsLookup;
42use MediaWiki\User\User;
43use MediaWiki\User\UserFactory;
44use MediaWiki\User\UserIdentity;
45use MediaWiki\User\UserIdentityLookup;
46use MediaWiki\User\UserNamePrefixSearch;
47use MediaWiki\User\UserNameUtils;
48use MediaWiki\User\UserRigorOptions;
49use Wikimedia\IPUtils;
50use Wikimedia\Rdbms\IConnectionProvider;
51
52/**
53 * Show user contributions in a paged list.
54 *
55 * This was refactored out from SpecialContributions to make it easier to
56 * add new special pages with similar functionality and similar output.
57 * Hooks formerly for SpecialContributions are run here to avoid needing
58 * to duplicate hooks for each subclass.
59 *
60 * The subclass must provide an implementation of ::getPager, and may
61 * disable syndication feed functionality by overriding ::providesFeeds.
62 *
63 * @stable to extend
64 * @ingroup SpecialPage
65 * @since 1.43 Refactored from SpecialContributions
66 */
67class ContributionsSpecialPage extends IncludableSpecialPage {
68
69    use ContributionsRangeTrait;
70
71    /** @var array */
72    protected $opts = [];
73    /** @var bool */
74    protected $formErrors = false;
75
76    protected IConnectionProvider $dbProvider;
77    protected NamespaceInfo $namespaceInfo;
78    protected PermissionManager $permissionManager;
79    protected UserNameUtils $userNameUtils;
80    protected UserNamePrefixSearch $userNamePrefixSearch;
81    protected UserOptionsLookup $userOptionsLookup;
82    protected UserFactory $userFactory;
83    protected UserIdentityLookup $userIdentityLookup;
84    protected DatabaseBlockStore $blockStore;
85
86    /**
87     * @param PermissionManager $permissionManager
88     * @param IConnectionProvider $dbProvider
89     * @param NamespaceInfo $namespaceInfo
90     * @param UserNameUtils $userNameUtils
91     * @param UserNamePrefixSearch $userNamePrefixSearch
92     * @param UserOptionsLookup $userOptionsLookup
93     * @param UserFactory $userFactory
94     * @param UserIdentityLookup $userIdentityLookup
95     * @param DatabaseBlockStore $blockStore
96     * @param string $name
97     * @param string $restriction
98     */
99    public function __construct(
100        PermissionManager $permissionManager,
101        IConnectionProvider $dbProvider,
102        NamespaceInfo $namespaceInfo,
103        UserNameUtils $userNameUtils,
104        UserNamePrefixSearch $userNamePrefixSearch,
105        UserOptionsLookup $userOptionsLookup,
106        UserFactory $userFactory,
107        UserIdentityLookup $userIdentityLookup,
108        DatabaseBlockStore $blockStore,
109        $name,
110        $restriction = ''
111    ) {
112        parent::__construct( $name, $restriction );
113        $this->permissionManager = $permissionManager;
114        $this->dbProvider = $dbProvider;
115        $this->namespaceInfo = $namespaceInfo;
116        $this->userNameUtils = $userNameUtils;
117        $this->userNamePrefixSearch = $userNamePrefixSearch;
118        $this->userOptionsLookup = $userOptionsLookup;
119        $this->userFactory = $userFactory;
120        $this->userIdentityLookup = $userIdentityLookup;
121        $this->blockStore = $blockStore;
122    }
123
124    /**
125     * @inheritDoc
126     */
127    public function execute( $par ) {
128        $this->setHeaders();
129        $this->outputHeader();
130        $this->checkPermissions();
131        $out = $this->getOutput();
132        // Modules required for viewing the list of contributions (also when included on other pages)
133        $out->addModuleStyles( [
134            'jquery.makeCollapsible.styles',
135            'mediawiki.interface.helpers.styles',
136            'mediawiki.special',
137            'mediawiki.special.changeslist',
138        ] );
139        $out->addBodyClasses( 'mw-special-ContributionsSpecialPage' );
140        $out->addModules( [
141            // Certain skins e.g. Minerva might have disabled this module.
142            'mediawiki.page.ready'
143        ] );
144        $this->addHelpLink( 'Help:User contributions' );
145
146        $request = $this->getRequest();
147
148        $target = $par ?? $request->getVal( 'target', '' );
149        '@phan-var string $target'; // getVal does not return null here
150
151        // Normalize underscores that may be present in the target parameter
152        // if it was passed in as a path param, rather than a query param
153        // where HTMLForm may have already performed preprocessing (T372444).
154        $target = $this->userNameUtils->getCanonical( $target, UserNameUtils::RIGOR_NONE );
155
156        $this->opts['deletedOnly'] = $request->getBool( 'deletedOnly' );
157
158        // Explicitly check for false or empty string as this needs to account
159        // for the rare case where the target parameter is '0' which is a valid
160        // target but resolves to false in boolean context (T379515).
161        if ( $target === false || $target === '' ) {
162            $out->addHTML( $this->getForm( $this->opts ) );
163
164            return;
165        }
166
167        $user = $this->getUser();
168
169        $this->opts['limit'] = $request->getInt( 'limit', $this->userOptionsLookup->getIntOption( $user, 'rclimit' ) );
170        $this->opts['target'] = $target;
171        $this->opts['topOnly'] = $request->getBool( 'topOnly' );
172        $this->opts['newOnly'] = $request->getBool( 'newOnly' );
173        $this->opts['hideMinor'] = $request->getBool( 'hideMinor' );
174
175        $ns = $request->getVal( 'namespace', null );
176        if ( $ns !== null && $ns !== '' && $ns !== 'all' ) {
177            $this->opts['namespace'] = intval( $ns );
178        } else {
179            $this->opts['namespace'] = '';
180        }
181
182        // Backwards compatibility: Before using OOUI form the old HTML form had
183        // fields for nsInvert and associated. These have now been replaced with the
184        // wpFilters query string parameters. These are retained to keep old URIs working.
185        $this->opts['associated'] = $request->getBool( 'associated' );
186        $this->opts['nsInvert'] = (bool)$request->getVal( 'nsInvert' );
187        $nsFilters = $request->getArray( 'wpfilters', null );
188        if ( $nsFilters !== null ) {
189            $this->opts['associated'] = in_array( 'associated', $nsFilters );
190            $this->opts['nsInvert'] = in_array( 'nsInvert', $nsFilters );
191        }
192
193        $this->opts['tagfilter'] = array_filter( explode(
194            '|',
195            (string)$request->getVal( 'tagfilter' )
196        ), static function ( $el ) {
197            return $el !== '';
198        } );
199        $this->opts['tagInvert'] = $request->getBool( 'tagInvert' );
200
201        // Allows reverts to have the bot flag in recent changes. It is just here to
202        // be passed in the form at the top of the page
203        if ( $this->permissionManager->userHasRight( $user, 'markbotedits' ) && $request->getBool( 'bot' ) ) {
204            $this->opts['bot'] = '1';
205        }
206
207        $this->opts['year'] = $request->getIntOrNull( 'year' );
208        $this->opts['month'] = $request->getIntOrNull( 'month' );
209        $this->opts['start'] = $request->getVal( 'start' );
210        $this->opts['end'] = $request->getVal( 'end' );
211
212        $notExternal = !ExternalUserNames::isExternal( $target );
213        if ( $notExternal ) {
214            $nt = Title::makeTitleSafe( NS_USER, $target );
215            if ( !$nt ) {
216                $out->addHTML( $this->getForm( $this->opts ) );
217                return;
218            }
219            $target = $nt->getText();
220            if ( IPUtils::isValidRange( $target ) ) {
221                $target = IPUtils::sanitizeRange( $target );
222            }
223        }
224
225        $userObj = $this->userFactory->newFromName( $target, UserRigorOptions::RIGOR_NONE );
226        if ( !$userObj ) {
227            $out->addHTML( $this->getForm( $this->opts ) );
228            return;
229        }
230        $out->addSubtitle( $this->contributionsSub( $userObj, $target ) );
231        $out->setPageTitleMsg( $this->msg( $this->getResultsPageTitleMessageKey( $userObj ), $target ) );
232
233        # For IP ranges, we want the contributionsSub, but not the skin-dependent
234        # links under 'Tools', which may include irrelevant links like 'Logs'.
235        if ( $notExternal && !IPUtils::isValidRange( $target ) &&
236            ( $this->userNameUtils->isIP( $target ) || $userObj->isRegistered() )
237        ) {
238            // Don't add non-existent users, because hidden users
239            // that we add here will be removed later to pretend
240            // that they don't exist, and if users that actually don't
241            // exist are added here and then not removed, it exposes
242            // which users exist and are hidden vs. which actually don't
243            // exist. But, do set the relevant user for single IPs.
244            $this->getSkin()->setRelevantUser( $userObj );
245        }
246
247        $this->opts = ContribsPager::processDateFilter( $this->opts );
248
249        if ( $this->opts['namespace'] !== '' && $this->opts['namespace'] < NS_MAIN ) {
250            $this->getOutput()->wrapWikiMsg(
251                "<div class=\"mw-negative-namespace-not-supported error\">\n\$1\n</div>",
252                [ 'negative-namespace-not-supported' ]
253            );
254            $out->addHTML( $this->getForm( $this->opts ) );
255            return;
256        }
257
258        if ( $this->providesFeeds() ) {
259            $feedType = $request->getVal( 'feed' );
260
261            $feedParams = [
262                'action' => 'feedcontributions',
263                'user' => $target,
264            ];
265            if ( $this->opts['topOnly'] ) {
266                $feedParams['toponly'] = true;
267            }
268            if ( $this->opts['newOnly'] ) {
269                $feedParams['newonly'] = true;
270            }
271            if ( $this->opts['hideMinor'] ) {
272                $feedParams['hideminor'] = true;
273            }
274            if ( $this->opts['deletedOnly'] ) {
275                $feedParams['deletedonly'] = true;
276            }
277
278            if ( $this->opts['tagfilter'] !== [] ) {
279                $feedParams['tagfilter'] = $this->opts['tagfilter'];
280            }
281            if ( $this->opts['namespace'] !== '' ) {
282                $feedParams['namespace'] = $this->opts['namespace'];
283            }
284            // Don't use year and month for the feed URL, but pass them on if
285            // we redirect to API (if $feedType is specified)
286            if ( $feedType && isset( $this->opts['year'] ) ) {
287                $feedParams['year'] = $this->opts['year'];
288            }
289            if ( $feedType && isset( $this->opts['month'] ) ) {
290                $feedParams['month'] = $this->opts['month'];
291            }
292
293            if ( $feedType ) {
294                // Maintain some level of backwards compatibility
295                // If people request feeds using the old parameters, redirect to API
296                $feedParams['feedformat'] = $feedType;
297                $url = wfAppendQuery( wfScript( 'api' ), $feedParams );
298
299                $out->redirect( $url, '301' );
300
301                return;
302            }
303
304            // Add RSS/atom links
305            $this->addFeedLinks( $feedParams );
306        }
307
308        if ( $this->getHookRunner()->onSpecialContributionsBeforeMainOutput(
309            $notExternal ? $userObj->getId() : 0, $userObj, $this )
310        ) {
311            $out->addHTML( $this->getForm( $this->opts ) );
312            if ( $this->formErrors ) {
313                return;
314            }
315            // We want a pure UserIdentity for imported actors, so the first letter
316            // of them is in lowercase and queryable.
317            $userIdentity = $notExternal ? $userObj :
318                $this->userIdentityLookup->getUserIdentityByName( $target ) ?? $userObj;
319            $pager = $this->getPager( $userIdentity );
320            if (
321                IPUtils::isValidRange( $target ) &&
322                !$this->isQueryableRange( $target, $this->getConfig() )
323            ) {
324                // Valid range, but outside CIDR limit.
325                $limits = $this->getQueryableRangeLimit( $this->getConfig() );
326                $limit = $limits[ IPUtils::isIPv4( $target ) ? 'IPv4' : 'IPv6' ];
327                $out->addWikiMsg( 'sp-contributions-outofrange', $limit );
328            } else {
329                // @todo We just want a wiki ID here, not a "DB domain", but
330                // current status of MediaWiki conflates the two. See T235955.
331                $poolKey = $this->dbProvider->getReplicaDatabase()->getDomainID() . ':Special' . $this->mName . ':';
332                if ( $this->getUser()->isAnon() ) {
333                    $poolKey .= 'a:' . $this->getUser()->getName();
334                } else {
335                    $poolKey .= 'u:' . $this->getUser()->getId();
336                }
337                $work = new PoolCounterWorkViaCallback( 'Special' . $this->mName, $poolKey, [
338                    'doWork' => function () use ( $pager, $out, $target ) {
339                        if ( !$pager->getNumRows() ) {
340                            $out->addWikiMsg( 'nocontribs', $target );
341                        } else {
342                            # Show a message about replica DB lag, if applicable
343                            $lag = $pager->getDatabase()->getSessionLagStatus()['lag'];
344                            if ( $lag > 0 ) {
345                                $out->showLagWarning( $lag );
346                            }
347
348                            $output = $pager->getBody();
349                            if ( !$this->including() ) {
350                                $output = $pager->getNavigationBar() .
351                                    $output .
352                                    $pager->getNavigationBar();
353                            }
354                            $out->addHTML( $output );
355                        }
356                    },
357                    'error' => function () use ( $out ) {
358                        $msg = $this->getUser()->isAnon()
359                            ? 'sp-contributions-concurrency-ip'
360                            : 'sp-contributions-concurrency-user';
361                        $out->addHTML(
362                            Html::errorBox(
363                                $out->msg( $msg )->parse()
364                            )
365                        );
366                    }
367                ] );
368                $work->execute();
369            }
370
371            $out->setPreventClickjacking( $pager->getPreventClickjacking() );
372
373            # Show the appropriate "footer" message - WHOIS tools, etc.
374            if ( $this->isQueryableRange( $target, $this->getConfig() )
375            ) {
376                $message = 'sp-contributions-footer-anon-range';
377            } elseif ( IPUtils::isIPAddress( $target ) ) {
378                $message = 'sp-contributions-footer-anon';
379            } elseif ( $userObj->isAnon() ) {
380                // No message for non-existing users
381                $message = '';
382            } elseif ( $userObj->isHidden() &&
383                !$this->permissionManager->userHasRight( $this->getUser(), 'hideuser' )
384            ) {
385                // User is registered, but make sure that the viewer can't see them, to avoid
386                // having different behavior for missing and hidden users; see T120883
387                $message = '';
388            } else {
389                // Not hidden, or hidden but the viewer can still see it
390                $message = 'sp-contributions-footer';
391            }
392
393            if ( $message && !$this->including() && !$this->msg( $message, $target )->isDisabled() ) {
394                $out->wrapWikiMsg(
395                    "<div class='mw-contributions-footer'>\n$1\n</div>",
396                    [ $message, $target ] );
397            }
398        }
399    }
400
401    /**
402     * Generates the subheading with links
403     * @param User $userObj User object for the target
404     * @param string $targetName This mostly the same as $userObj->getName() but
405     * normalization may make it differ. // T272225
406     * @return string Appropriately-escaped HTML to be output literally
407     */
408    protected function contributionsSub( $userObj, $targetName ) {
409        $isAnon = $userObj->isAnon();
410        if ( !$isAnon && $userObj->isHidden() &&
411            !$this->permissionManager->userHasRight( $this->getUser(), 'hideuser' )
412        ) {
413            // T120883 if the user is hidden and the viewer cannot see hidden
414            // users, pretend like it does not exist at all.
415            $isAnon = true;
416        }
417
418        if ( $isAnon ) {
419            // Show a warning message that the user being searched for doesn't exist.
420            // UserNameUtils::isIP returns true for IP address and usemod IPs like '123.123.123.xxx',
421            // but returns false for IP ranges. We don't want to suggest either of these are
422            // valid usernames which we would with the 'contributions-userdoesnotexist' message.
423            if ( !$this->userNameUtils->isIP( $userObj->getName() )
424                && !IPUtils::isValidRange( $userObj->getName() )
425            ) {
426                $this->getOutput()->addHTML( Html::warningBox(
427                    $this->getOutput()->msg( 'contributions-userdoesnotexist',
428                        wfEscapeWikiText( $userObj->getName() ) )->parse(),
429                    'mw-userpage-userdoesnotexist'
430                ) );
431                if ( !$this->including() ) {
432                    $this->getOutput()->setStatusCode( 404 );
433                }
434            }
435            $user = htmlspecialchars( $userObj->getName() );
436        } else {
437            $user = $this->getLinkRenderer()->makeLink( $userObj->getUserPage(), $userObj->getName() );
438        }
439        $nt = $userObj->getUserPage();
440        $talk = $userObj->getTalkPage();
441        $links = '';
442
443        // T211910. Don't show action links if a range is outside block limit
444        $showForIp = $this->isValidIPOrQueryableRange( $userObj, $this->getConfig() );
445
446        // T276306. if the user is hidden and the viewer cannot see hidden, pretend that it does not exist
447        $registeredAndVisible = $userObj->isRegistered() && ( !$userObj->isHidden()
448                || $this->permissionManager->userHasRight( $this->getUser(), 'hideuser' ) );
449
450        $shouldShowLinks = $talk && ( $registeredAndVisible || $showForIp );
451        if ( $shouldShowLinks ) {
452            $tools = $this->getUserLinks(
453                $this,
454                $userObj
455            );
456            $links = Html::openElement( 'span', [ 'class' => 'mw-changeslist-links' ] );
457            foreach ( $tools as $tool ) {
458                $links .= Html::rawElement( 'span', [], $tool ) . ' ';
459            }
460            $links = trim( $links ) . Html::closeElement( 'span' );
461
462            // Show a note if the user is blocked and display the last block log entry.
463            // Do not expose the autoblocks, since that may lead to a leak of accounts' IPs,
464            // and also this will display a totally irrelevant log entry as a current block.
465            $shouldShowBlocks = $this->shouldShowBlockLogExtract( $userObj );
466            if ( $shouldShowBlocks ) {
467                // For IP ranges you must give DatabaseBlock::newFromTarget the CIDR string
468                // and not a user object.
469                if ( IPUtils::isValidRange( $userObj->getName() ) ) {
470                    $block = $this->blockStore
471                        ->newFromTarget( $userObj->getName(), $userObj->getName() );
472                } else {
473                    $block = $this->blockStore->newFromTarget( $userObj, $userObj );
474                }
475
476                if ( $block !== null && $block->getType() != Block::TYPE_AUTO ) {
477                    if ( $block->getType() == Block::TYPE_RANGE ) {
478                        $nt = $this->namespaceInfo->getCanonicalName( NS_USER )
479                            . ':' . $block->getTargetName();
480                    }
481
482                    $out = $this->getOutput(); // showLogExtract() wants first parameter by reference
483                    if ( $userObj->isAnon() ) {
484                        $msgKey = $block->isSitewide() ?
485                            'sp-contributions-blocked-notice-anon' :
486                            'sp-contributions-blocked-notice-anon-partial';
487                    } else {
488                        $msgKey = $block->isSitewide() ?
489                            'sp-contributions-blocked-notice' :
490                            'sp-contributions-blocked-notice-partial';
491                    }
492                    // Allow local styling overrides for different types of block
493                    $class = $block->isSitewide() ?
494                        'mw-contributions-blocked-notice' :
495                        'mw-contributions-blocked-notice-partial';
496                    LogEventsList::showLogExtract(
497                        $out,
498                        'block',
499                        $nt,
500                        '',
501                        [
502                            'lim' => 1,
503                            'showIfEmpty' => false,
504                            'msgKey' => [
505                                $msgKey,
506                                $userObj->getName() # Support GENDER in 'sp-contributions-blocked-notice'
507                            ],
508                            'offset' => '', # don't use WebRequest parameter offset
509                            'wrap' => Html::rawElement(
510                                'div',
511                                [ 'class' => $class ],
512                                '$1'
513                            ),
514                        ]
515                    );
516                }
517            }
518        }
519
520        // First subheading. "For Username (talk | block log | logs | etc.)"
521        $userName = $userObj->getName();
522        $subHeadingsHtml = Html::rawElement( 'div', [ 'class' => 'mw-contributions-user-tools' ],
523            $this->msg( 'contributions-subtitle' )->rawParams( $user )->params( $userName )
524            . ' ' . $links
525        );
526
527        // Second subheading. "A user with 37,208 edits. Account created on 2008-09-17."
528        if ( $talk && $registeredAndVisible ) {
529            $editCount = $userObj->getEditCount();
530            $userInfo = $this->msg( 'contributions-edit-count' )
531                ->params( $userName )
532                ->numParams( $editCount )
533                ->escaped();
534
535            $accountCreationDate = $userObj->getRegistration();
536            if ( $accountCreationDate ) {
537                $date = $this->getLanguage()->date( $accountCreationDate, true );
538                $userInfo .= $this->msg( 'word-separator' )
539                    ->escaped();
540                $userInfo .= $this->msg( 'contributions-account-creation-date' )
541                    ->plaintextParams( $date )
542                    ->escaped();
543            }
544
545            $subHeadingsHtml .= Html::rawElement(
546                'div',
547                [ 'class' => 'mw-contributions-editor-info' ],
548                $userInfo
549            );
550        }
551
552        return $subHeadingsHtml;
553    }
554
555    /**
556     * Links to different places.
557     *
558     * @note This function is also called in DeletedContributionsPage
559     * @param SpecialPage $sp SpecialPage instance, for context
560     * @param User $target Target user object
561     * @return array
562     */
563    protected function getUserLinks(
564        SpecialPage $sp,
565        User $target
566    ) {
567        $id = $target->getId();
568        $username = $target->getName();
569        $userpage = $target->getUserPage();
570        $talkpage = $target->getTalkPage();
571        $isIP = IPUtils::isValid( $username );
572        $isRange = IPUtils::isValidRange( $username );
573
574        $linkRenderer = $sp->getLinkRenderer();
575
576        $tools = [];
577        # No talk pages for IP ranges.
578        if ( !$isRange ) {
579            $tools['user-talk'] = $linkRenderer->makeLink(
580                $talkpage,
581                $sp->msg( 'sp-contributions-talk' )->text(),
582                [ 'class' => 'mw-contributions-link-talk' ]
583            );
584        }
585
586        # Block / Change block / Unblock links
587        if ( $this->permissionManager->userHasRight( $sp->getUser(), 'block' ) ) {
588            if ( $target->getBlock() && $target->getBlock()->getType() != Block::TYPE_AUTO ) {
589                $tools['block'] = $linkRenderer->makeKnownLink( # Change block link
590                    SpecialPage::getTitleFor( 'Block', $username ),
591                    $sp->msg( 'change-blocklink' )->text(),
592                    [ 'class' => 'mw-contributions-link-change-block' ]
593                );
594                $tools['unblock'] = $linkRenderer->makeKnownLink( # Unblock link
595                    SpecialPage::getTitleFor( 'Unblock', $username ),
596                    $sp->msg( 'unblocklink' )->text(),
597                    [ 'class' => 'mw-contributions-link-unblock' ]
598                );
599            } else { # User is not blocked
600                $tools['block'] = $linkRenderer->makeKnownLink( # Block link
601                    SpecialPage::getTitleFor( 'Block', $username ),
602                    $sp->msg( 'blocklink' )->text(),
603                    [ 'class' => 'mw-contributions-link-block' ]
604                );
605            }
606        }
607
608        # Block log link
609        $tools['log-block'] = $linkRenderer->makeKnownLink(
610            SpecialPage::getTitleFor( 'Log', 'block' ),
611            $sp->msg( 'sp-contributions-blocklog' )->text(),
612            [ 'class' => 'mw-contributions-link-block-log' ],
613            [ 'page' => $userpage->getPrefixedText() ]
614        );
615
616        # Suppression log link (T61120)
617        if ( $this->permissionManager->userHasRight( $sp->getUser(), 'suppressionlog' ) ) {
618            $tools['log-suppression'] = $linkRenderer->makeKnownLink(
619                SpecialPage::getTitleFor( 'Log', 'suppress' ),
620                $sp->msg( 'sp-contributions-suppresslog', $username )->text(),
621                [ 'class' => 'mw-contributions-link-suppress-log' ],
622                [ 'offender' => $username ]
623            );
624        }
625
626        # Don't show some links for IP ranges
627        if ( !$isRange ) {
628            # Uploads: hide if IPs cannot upload (T220674)
629            if ( !$isIP || $this->permissionManager->userHasRight( $target, 'upload' ) ) {
630                $tools['uploads'] = $linkRenderer->makeKnownLink(
631                    SpecialPage::getTitleFor( 'Listfiles', $username ),
632                    $sp->msg( 'sp-contributions-uploads' )->text(),
633                    [ 'class' => 'mw-contributions-link-uploads' ]
634                );
635            }
636
637            # Other logs link
638            # Todo: T146628
639            $tools['logs'] = $linkRenderer->makeKnownLink(
640                SpecialPage::getTitleFor( 'Log', $username ),
641                $sp->msg( 'sp-contributions-logs' )->text(),
642                [ 'class' => 'mw-contributions-link-logs' ]
643            );
644
645            # Add link to deleted user contributions for privileged users
646            # Todo: T183457
647            if ( $this->permissionManager->userHasRight( $sp->getUser(), 'deletedhistory' ) ) {
648                $tools['deletedcontribs'] = $linkRenderer->makeKnownLink(
649                    SpecialPage::getTitleFor( 'DeletedContributions', $username ),
650                    $sp->msg( 'sp-contributions-deleted', $username )->text(),
651                    [ 'class' => 'mw-contributions-link-deleted-contribs' ]
652                );
653            }
654        }
655
656        # (T373988) Don't show some links for temporary accounts
657        if ( !$target->isTemp() ) {
658            # Add a link to change user rights for privileged users
659            $userrightsPage = new SpecialUserRights();
660            $userrightsPage->setContext( $sp->getContext() );
661            if ( $userrightsPage->userCanChangeRights( $target ) ) {
662                $tools['userrights'] = $linkRenderer->makeKnownLink(
663                    SpecialPage::getTitleFor( 'Userrights', $username ),
664                    $sp->msg( 'sp-contributions-userrights', $username )->text(),
665                    [ 'class' => 'mw-contributions-link-user-rights' ]
666                );
667            }
668
669            # Add a link to rename the user
670            if ( $id && $this->permissionManager->userHasRight( $sp->getUser(), 'renameuser' ) ) {
671                $tools['renameuser'] = $sp->getLinkRenderer()->makeKnownLink(
672                    SpecialPage::getTitleFor( 'Renameuser' ),
673                    $sp->msg( 'renameuser-linkoncontribs', $userpage->getText() )->text(),
674                    [ 'title' => $sp->msg( 'renameuser-linkoncontribs-text', $userpage->getText() )->parse() ],
675                    [ 'oldusername' => $userpage->getText() ]
676                );
677            }
678        }
679
680        $this->getHookRunner()->onContributionsToolLinks( $id, $userpage, $tools, $sp );
681
682        return $tools;
683    }
684
685    /**
686     * Get the target field for the form
687     *
688     * @param string $target
689     * @return array
690     */
691    protected function getTargetField( $target ) {
692        return [
693            'type' => 'user',
694            'default' => str_replace( '_', ' ', $target ),
695            'label' => $this->msg( 'sp-contributions-username' )->text(),
696            'name' => 'target',
697            'id' => 'mw-target-user-or-ip',
698            'size' => 40,
699            'autofocus' => $target === '',
700            'section' => 'contribs-top',
701            'ipallowed' => true,
702            'usemodwiki-ipallowed' => true,
703            'iprange' => true,
704            'external' => true,
705            'required' => true,
706        ];
707    }
708
709    /**
710     * Generates the namespace selector form with hidden attributes.
711     * @param array $pagerOptions with keys contribs, user, deletedOnly, limit, target, topOnly,
712     *  newOnly, hideMinor, namespace, associated, nsInvert, tagfilter, tagInvert, year, start, end
713     * @return string HTML fragment
714     */
715    protected function getForm( array $pagerOptions ) {
716        if ( $this->including() ) {
717            // Do not show a form when special page is included in wikitext
718            return '';
719        }
720
721        // Modules required only for the form
722        $this->getOutput()->addModules( [
723            'mediawiki.special.contributions',
724        ] );
725        $this->getOutput()->enableOOUI();
726        $fields = [];
727
728        # Add hidden params for tracking except for parameters in $skipParameters
729        $skipParameters = [
730            'namespace',
731            'nsInvert',
732            'deletedOnly',
733            'target',
734            'year',
735            'month',
736            'start',
737            'end',
738            'topOnly',
739            'newOnly',
740            'hideMinor',
741            'associated',
742            'tagfilter',
743            'tagInvert',
744            'title',
745        ];
746
747        foreach ( $this->opts as $name => $value ) {
748            if ( in_array( $name, $skipParameters ) ) {
749                continue;
750            }
751
752            $fields[$name] = [
753                'name' => $name,
754                'type' => 'hidden',
755                'default' => $value,
756            ];
757        }
758
759        $target = $this->opts['target'] ?? '';
760        $fields['target'] = $this->getTargetField( $target );
761
762        $ns = $this->opts['namespace'] ?? null;
763        $fields['namespace'] = [
764            'type' => 'namespaceselect',
765            'label' => $this->msg( 'namespace' )->text(),
766            'name' => 'namespace',
767            'cssclass' => 'namespaceselector',
768            'default' => $ns,
769            'id' => 'namespace',
770            'section' => 'contribs-top',
771        ];
772        $fields['nsFilters'] = [
773            'class' => HTMLMultiSelectField::class,
774            'label' => '',
775            'name' => 'wpfilters',
776            'flatlist' => true,
777            // Only shown when namespaces are selected.
778            'hide-if' => [ '===', 'namespace', 'all' ],
779            'options-messages' => [
780                'invert' => 'nsInvert',
781                'namespace_association' => 'associated',
782            ],
783            'section' => 'contribs-top',
784        ];
785        $fields['tagfilter'] = [
786            'type' => 'tagfilter',
787            'cssclass' => 'mw-tagfilter-input',
788            'id' => 'tagfilter',
789            'label-message' => [ 'tag-filter', 'parse' ],
790            'name' => 'tagfilter',
791            'size' => 20,
792            'section' => 'contribs-top',
793        ];
794        $fields['tagInvert'] = [
795            'type' => 'check',
796            'id' => 'tagInvert',
797            'label' => $this->msg( 'invert' ),
798            'name' => 'tagInvert',
799            'hide-if' => [ '===', 'tagfilter', '' ],
800            'section' => 'contribs-top',
801        ];
802
803        if ( $this->permissionManager->userHasRight( $this->getUser(), 'deletedhistory' ) ) {
804            $fields['deletedOnly'] = [
805                'type' => 'check',
806                'id' => 'mw-show-deleted-only',
807                'label' => $this->msg( 'history-show-deleted' )->text(),
808                'name' => 'deletedOnly',
809                'section' => 'contribs-top',
810            ];
811        }
812
813        if ( !$this->isArchive() ) {
814            $fields['topOnly'] = [
815                'type' => 'check',
816                'id' => 'mw-show-top-only',
817                'label' => $this->msg( 'sp-contributions-toponly' )->text(),
818                'name' => 'topOnly',
819                'section' => 'contribs-top',
820            ];
821        }
822
823        $fields['newOnly'] = [
824            'type' => 'check',
825            'id' => 'mw-show-new-only',
826            'label' => $this->msg( 'sp-contributions-newonly' )->text(),
827            'name' => 'newOnly',
828            'section' => 'contribs-top',
829        ];
830        $fields['hideMinor'] = [
831            'type' => 'check',
832            'cssclass' => 'mw-hide-minor-edits',
833            'id' => 'mw-show-new-only',
834            'label' => $this->msg( 'sp-contributions-hideminor' )->text(),
835            'name' => 'hideMinor',
836            'section' => 'contribs-top',
837        ];
838
839        // Allow additions at this point to the filters.
840        $rawFilters = [];
841        $this->getHookRunner()->onSpecialContributions__getForm__filters(
842            $this, $rawFilters );
843        foreach ( $rawFilters as $filter ) {
844            // Backwards compatibility support for previous hook function signature.
845            if ( is_string( $filter ) ) {
846                $fields[] = [
847                    'type' => 'info',
848                    'default' => $filter,
849                    'raw' => true,
850                    'section' => 'contribs-top',
851                ];
852                wfDeprecatedMsg(
853                    'A SpecialContributions::getForm::filters hook handler returned ' .
854                    'an array of strings, this is deprecated since MediaWiki 1.33',
855                    '1.33', false, false
856                );
857            } else {
858                // Preferred append method.
859                $fields[] = $filter;
860            }
861        }
862
863        $fields['start'] = [
864            'type' => 'date',
865            'default' => '',
866            'id' => 'mw-date-start',
867            'label' => $this->msg( 'date-range-from' )->text(),
868            'name' => 'start',
869            'section' => 'contribs-date',
870        ];
871        $fields['end'] = [
872            'type' => 'date',
873            'default' => '',
874            'id' => 'mw-date-end',
875            'label' => $this->msg( 'date-range-to' )->text(),
876            'name' => 'end',
877            'section' => 'contribs-date',
878        ];
879
880        // Allow children classes to modify field options before generating HTML
881        $this->modifyFields( $fields );
882
883        $htmlForm = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
884        $htmlForm
885            ->setMethod( 'get' )
886            ->setTitle( $this->getPageTitle() )
887            // When offset is defined, the user is paging through results
888            // so we hide the form by default to allow users to focus on browsing
889            // rather than defining search parameters
890            ->setCollapsibleOptions(
891                ( $pagerOptions['target'] ?? null ) ||
892                ( $pagerOptions['start'] ?? null ) ||
893                ( $pagerOptions['end'] ?? null )
894            )
895            ->setAction( wfScript() )
896            ->setSubmitTextMsg( 'sp-contributions-submit' )
897            ->setWrapperLegendMsg( $this->getFormWrapperLegendMessageKey() );
898
899        $htmlForm->prepareForm();
900
901        // Submission is handled elsewhere, but do this to check for and display errors
902        $htmlForm->setSubmitCallback( static function () {
903            return true;
904        } );
905        $result = $htmlForm->tryAuthorizedSubmit();
906        if ( !( $result === true || ( $result instanceof Status && $result->isGood() ) ) ) {
907            // Uncollapse if there are errors
908            $htmlForm->setCollapsibleOptions( false );
909            $this->formErrors = true;
910        }
911
912        return $htmlForm->getHTML( $result );
913    }
914
915    /**
916     * Allow children classes to call this function and make modifications to the
917     * field options before they're used to create the form in getForm.
918     *
919     * @since 1.44
920     * @param array &$fields
921     */
922    protected function modifyFields( &$fields ) {
923    }
924
925    /**
926     * Return an array of subpages beginning with $search that this special page will accept.
927     *
928     * @param string $search Prefix to search for
929     * @param int $limit Maximum number of results to return (usually 10)
930     * @param int $offset Number of results to skip (usually 0)
931     * @return string[] Matching subpages
932     */
933    public function prefixSearchSubpages( $search, $limit, $offset ) {
934        $search = $this->userNameUtils->getCanonical( $search );
935        if ( !$search ) {
936            // No prefix suggestion for invalid user
937            return [];
938        }
939        // Autocomplete subpage as user list - public to allow caching
940        return $this->userNamePrefixSearch
941            ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset );
942    }
943
944    /**
945     * @return bool This SpecialPage provides syndication feeds.
946     */
947    protected function providesFeeds() {
948        return true;
949    }
950
951    /**
952     * Define whether this page shows existing revisions (from the revision table) or
953     * revisions of deleted pages (from the archive table).
954     *
955     * @return bool This page shows existing revisions
956     */
957    protected function isArchive() {
958        return false;
959    }
960
961    /**
962     * @param UserIdentity $targetUser The normalized target user identity
963     * @return ContributionsPager
964     */
965    protected function getPager( $targetUser ) {
966        // TODO: This class and the classes it extends should be abstract, and this
967        // method should be abstract.
968        throw new \LogicException( __METHOD__ . " must be overridden" );
969    }
970
971    /**
972     * @inheritDoc
973     */
974    protected function getGroupName() {
975        return 'users';
976    }
977
978    /**
979     * @return string Message key for the fieldset wrapping the form
980     */
981    protected function getFormWrapperLegendMessageKey() {
982        return 'sp-contributions-search';
983    }
984
985    /**
986     * @param UserIdentity $target The target of the search that produced the results page
987     * @return string Message key for the results page title
988     */
989    protected function getResultsPageTitleMessageKey( UserIdentity $target ) {
990        return 'contributions-title';
991    }
992
993    /**
994     * Whether the block log extract should be shown on the special page. This is public to allow extensions which
995     * add block log entries to skip adding them when this returns false.
996     *
997     * @since 1.43
998     * @param UserIdentity $target The target of the search that produced the results page
999     * @return bool Whether the block log extract should be shown if the target is blocked.
1000     */
1001    public function shouldShowBlockLogExtract( UserIdentity $target ): bool {
1002        return !$this->including();
1003    }
1004}