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