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