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