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