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