33 use Wikimedia\IPUtils;
96 parent::__construct(
'Contributions' );
98 $services = MediaWikiServices::getInstance();
101 $this->loadBalancer =
$loadBalancer ?? $services->getDBLoadBalancer();
102 $this->actorMigration =
$actorMigration ?? $services->getActorMigration();
103 $this->revisionStore =
$revisionStore ?? $services->getRevisionStore();
104 $this->namespaceInfo =
$namespaceInfo ?? $services->getNamespaceInfo();
105 $this->userNameUtils =
$userNameUtils ?? $services->getUserNameUtils();
115 $out->addModuleStyles( [
116 'jquery.makeCollapsible.styles',
117 'mediawiki.interface.helpers.styles',
119 'mediawiki.special.changeslist',
122 'mediawiki.special.recentchanges',
124 'mediawiki.page.ready'
131 $target = $par ?? $request->getVal(
'target' );
133 $this->opts[
'deletedOnly'] = $request->getBool(
'deletedOnly' );
135 if ( !strlen( $target ) ) {
137 $out->addHTML( $this->
getForm( $this->opts ) );
145 $this->opts[
'limit'] = $request->getInt(
'limit', $this->userOptionsLookup->getIntOption( $user,
'rclimit' ) );
146 $this->opts[
'target'] = $target;
147 $this->opts[
'topOnly'] = $request->getBool(
'topOnly' );
148 $this->opts[
'newOnly'] = $request->getBool(
'newOnly' );
149 $this->opts[
'hideMinor'] = $request->getBool(
'hideMinor' );
151 $ns = $request->getVal(
'namespace',
null );
152 if ( $ns !==
null && $ns !==
'' && $ns !==
'all' ) {
153 $this->opts[
'namespace'] = intval( $ns );
155 $this->opts[
'namespace'] =
'';
161 $this->opts[
'associated'] = $request->getBool(
'associated' );
162 $this->opts[
'nsInvert'] = (bool)$request->getVal(
'nsInvert' );
163 $nsFilters = $request->getArray(
'wpfilters',
null );
164 if ( $nsFilters !==
null ) {
165 $this->opts[
'associated'] = in_array(
'associated', $nsFilters );
166 $this->opts[
'nsInvert'] = in_array(
'nsInvert', $nsFilters );
169 $this->opts[
'tagfilter'] = (string)$request->getVal(
'tagfilter' );
173 if ( MediaWikiServices::getInstance()
174 ->getPermissionManager()
175 ->userHasRight( $user,
'markbotedits' ) && $request->getBool(
'bot' )
177 $this->opts[
'bot'] =
'1';
180 $skip = $request->getText(
'offset' ) || $request->getText(
'dir' ) ==
'prev';
181 # Offset overrides year/month selection
183 $this->opts[
'year'] = $request->getVal(
'year' );
184 $this->opts[
'month'] = $request->getVal(
'month' );
186 $this->opts[
'start'] = $request->getVal(
'start' );
187 $this->opts[
'end'] = $request->getVal(
'end' );
194 $out->addHTML( $this->
getForm( $this->opts ) );
199 $out->setHTMLTitle( $this->
msg(
201 $this->
msg(
'contributions-title', $target )->plain()
202 )->inContentLanguage() );
206 $out->addHTML( $this->
getForm( $this->opts ) );
211 $out->addHTML( $this->
getForm( $this->opts ) );
214 $id = $userObj->getId();
216 $target = $nt->getText();
218 $out->setHTMLTitle( $this->
msg(
220 $this->
msg(
'contributions-title', $target )->plain()
221 )->inContentLanguage() );
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 ( !IPUtils::isValidRange( $target ) &&
226 (
User::isIP( $target ) || $userObj->isRegistered() )
234 $this->
getSkin()->setRelevantUser( $userObj );
240 if ( $this->opts[
'namespace'] <
NS_MAIN ) {
242 "<div class=\"mw-negative-namespace-not-supported error\">\n\$1\n</div>",
243 [
'negative-namespace-not-supported' ]
245 $out->addHTML( $this->
getForm( $this->opts ) );
249 $feedType = $request->getVal(
'feed' );
252 'action' =>
'feedcontributions',
255 if ( $this->opts[
'topOnly'] ) {
256 $feedParams[
'toponly'] =
true;
258 if ( $this->opts[
'newOnly'] ) {
259 $feedParams[
'newonly'] =
true;
261 if ( $this->opts[
'hideMinor'] ) {
262 $feedParams[
'hideminor'] =
true;
264 if ( $this->opts[
'deletedOnly'] ) {
265 $feedParams[
'deletedonly'] =
true;
267 if ( $this->opts[
'tagfilter'] !==
'' ) {
268 $feedParams[
'tagfilter'] = $this->opts[
'tagfilter'];
270 if ( $this->opts[
'namespace'] !==
'' ) {
271 $feedParams[
'namespace'] = $this->opts[
'namespace'];
275 if ( $feedType && isset( $this->opts[
'year'] ) ) {
276 $feedParams[
'year'] = $this->opts[
'year'];
278 if ( $feedType && isset( $this->opts[
'month'] ) ) {
279 $feedParams[
'month'] = $this->opts[
'month'];
285 $feedParams[
'feedformat'] = $feedType;
288 $out->redirect( $url,
'301' );
296 if ( $this->
getHookRunner()->onSpecialContributionsBeforeMainOutput(
297 $id, $userObj, $this )
300 $out->addHTML( $this->
getForm( $this->opts ) );
305 $limits = $this->
getConfig()->get(
'RangeContributionsCIDRLimit' );
306 $limit = $limits[ IPUtils::isIPv4( $target ) ?
'IPv4' :
'IPv6' ];
307 $out->addWikiMsg(
'sp-contributions-outofrange', $limit );
309 $out->addWikiMsg(
'nocontribs', $target );
313 $poolKey = $this->loadBalancer->getLocalDomainID() .
':SpecialContributions:';
314 if ( $this->
getUser()->isAnon() ) {
315 $poolKey .=
'a:' . $this->
getUser()->getName();
317 $poolKey .=
'u:' . $this->
getUser()->getId();
320 'doWork' =>
function () use (
$pager, $out ) {
321 # Show a message about replica DB lag, if applicable
324 $out->showLagWarning( $lag );
333 $out->addHTML( $output );
335 'error' =>
function () use ( $out ) {
336 $msg = $this->
getUser()->isAnon()
337 ?
'sp-contributions-concurrency-ip'
338 :
'sp-contributions-concurrency-user';
339 $out->wrapWikiMsg(
"<div class='errorbox'>\n$1\n</div>", $msg );
347 # Show the appropriate "footer" message - WHOIS tools, etc.
349 $message =
'sp-contributions-footer-anon-range';
350 } elseif ( IPUtils::isIPAddress( $target ) ) {
351 $message =
'sp-contributions-footer-anon';
352 } elseif ( $userObj->isAnon() ) {
355 } elseif ( $userObj->isHidden() &&
356 !$this->permissionManager->userHasRight( $this->getUser(),
'hideuser' )
363 $message =
'sp-contributions-footer';
366 if ( $message && !$this->
including() && !$this->
msg( $message, $target )->isDisabled() ) {
368 "<div class='mw-contributions-footer'>\n$1\n</div>",
369 [ $message, $target ] );
382 $isAnon = $userObj->isAnon();
383 if ( !$isAnon && $userObj->isHidden() &&
384 !$this->permissionManager->userHasRight( $this->getUser(),
'hideuser' )
396 if ( !
User::isIP( $userObj->getName() ) && !$userObj->isIPRange() ) {
398 "<div class=\"mw-userpage-userdoesnotexist error\">\n\$1\n</div>",
400 'contributions-userdoesnotexist',
405 $this->
getOutput()->setStatusCode( 404 );
408 $user = htmlspecialchars( $userObj->getName() );
410 $user = $this->
getLinkRenderer()->makeLink( $userObj->getUserPage(), $userObj->getName() );
412 $nt = $userObj->getUserPage();
413 $talk = $userObj->getTalkPage();
417 $showForIp = IPUtils::isValid( $userObj ) ||
418 ( IPUtils::isValidRange( $userObj ) && $this->
getPager()->isQueryableRange( $userObj ) );
420 if ( $talk && ( $userObj->isRegistered() || $showForIp ) ) {
423 foreach ( $tools as $tool ) {
434 if ( $userObj->isIPRange() ) {
435 $block = DatabaseBlock::newFromTarget( $userObj->getName(), $userObj->getName() );
437 $block = DatabaseBlock::newFromTarget( $userObj, $userObj );
440 if ( $block !==
null && $block->getType() != DatabaseBlock::TYPE_AUTO ) {
441 if ( $block->getType() == DatabaseBlock::TYPE_RANGE ) {
442 $nt = $this->namespaceInfo->getCanonicalName(
NS_USER ) .
':' . $block->getTarget();
446 if ( $userObj->isAnon() ) {
447 $msgKey = $block->isSitewide() ?
448 'sp-contributions-blocked-notice-anon' :
449 'sp-contributions-blocked-notice-anon-partial';
451 $msgKey = $block->isSitewide() ?
452 'sp-contributions-blocked-notice' :
453 'sp-contributions-blocked-notice-partial';
456 $class = $block->isSitewide() ?
457 'mw-contributions-blocked-notice' :
458 'mw-contributions-blocked-notice-partial';
466 'showIfEmpty' =>
false,
469 $userObj->getName() # Support GENDER in
'sp-contributions-blocked-notice'
471 'offset' =>
'', # don
't use WebRequest parameter offset
472 'wrap
' => Html::rawElement(
474 [ 'class' => $class ],
483 return Html::rawElement( 'div
', [ 'class' => 'mw-contributions-user-tools
' ],
484 $this->msg( 'contributions-subtitle
' )->rawParams( $user )->params( $userObj->getName() )
499 public static function getUserLinks(
502 PermissionManager $permissionManager = null,
503 HookRunner $hookRunner = null
505 // Fallback to global state, if not provided
506 $permissionManager = $permissionManager ?? MediaWikiServices::getInstance()->getPermissionManager();
507 $hookRunner = $hookRunner ?? Hooks::runner();
509 $id = $target->getId();
510 $username = $target->getName();
511 $userpage = $target->getUserPage();
512 $talkpage = $target->getTalkPage();
513 $isIP = IPUtils::isValid( $username );
514 $isRange = IPUtils::isValidRange( $username );
516 $linkRenderer = $sp->getLinkRenderer();
519 # No talk pages for IP ranges.
521 $tools['user-talk
'] = $linkRenderer->makeLink(
523 $sp->msg( 'sp-contributions-talk
' )->text()
527 # Block / Change block / Unblock links
528 if ( $permissionManager->userHasRight( $sp->getUser(), 'block
' ) ) {
529 if ( $target->getBlock() && $target->getBlock()->getType() != DatabaseBlock::TYPE_AUTO ) {
530 $tools['block
'] = $linkRenderer->makeKnownLink( # Change block link
531 SpecialPage::getTitleFor( 'Block
', $username ),
532 $sp->msg( 'change-blocklink
' )->text()
534 $tools['unblock
'] = $linkRenderer->makeKnownLink( # Unblock link
535 SpecialPage::getTitleFor( 'Unblock
', $username ),
536 $sp->msg( 'unblocklink
' )->text()
538 } else { # User is not blocked
539 $tools['block
'] = $linkRenderer->makeKnownLink( # Block link
540 SpecialPage::getTitleFor( 'Block
', $username ),
541 $sp->msg( 'blocklink
' )->text()
547 $tools['log-block
'] = $linkRenderer->makeKnownLink(
548 SpecialPage::getTitleFor( 'Log
', 'block
' ),
549 $sp->msg( 'sp-contributions-blocklog
' )->text(),
551 [ 'page
' => $userpage->getPrefixedText() ]
554 # Suppression log link (T61120)
555 if ( $permissionManager->userHasRight( $sp->getUser(), 'suppressionlog
' ) ) {
556 $tools['log-suppression
'] = $linkRenderer->makeKnownLink(
557 SpecialPage::getTitleFor( 'Log
', 'suppress
' ),
558 $sp->msg( 'sp-contributions-suppresslog
', $username )->text(),
560 [ 'offender
' => $username ]
564 # Don't show some links
for IP ranges
566 # Uploads: hide if IPs cannot upload (T220674)
568 $tools[
'uploads'] = $linkRenderer->makeKnownLink(
569 SpecialPage::getTitleFor(
'Listfiles', $username ),
570 $sp->msg(
'sp-contributions-uploads' )->text()
578 $sp->msg(
'sp-contributions-logs' )->text()
581 # Add link to deleted user contributions
for priviledged users
584 $tools[
'deletedcontribs'] = $linkRenderer->makeKnownLink(
585 SpecialPage::getTitleFor(
'DeletedContributions', $username ),
586 $sp->msg(
'sp-contributions-deleted', $username )->text()
591 # Add a link to change user rights for privileged users
593 $userrightsPage->setContext( $sp->getContext() );
594 if ( $userrightsPage->userCanChangeRights( $target ) ) {
597 $sp->msg(
'sp-contributions-userrights', $username )->text()
612 protected function getForm( array $pagerOptions ) {
613 $this->opts[
'title'] = $this->
getPageTitle()->getPrefixedText();
616 'mediawiki.special.contributions',
618 $this->
getOutput()->addModuleStyles(
'mediawiki.widgets.DateInputWidget.styles' );
622 # Add hidden params for tracking except for parameters in $skipParameters
639 foreach ( $this->opts as $name => $value ) {
640 if ( in_array( $name, $skipParameters ) ) {
651 $target = $this->opts[
'target'] ??
null;
652 $fields[
'target'] = [
654 'default' => $target ?
655 str_replace(
'_',
' ', $target ) :
'' ,
656 'label' => $this->
msg(
'sp-contributions-username' )->text(),
658 'id' =>
'mw-target-user-or-ip',
660 'autofocus' => !$target,
661 'section' =>
'contribs-top',
664 $ns = $this->opts[
'namespace'] ??
null;
665 $fields[
'namespace'] = [
666 'type' =>
'namespaceselect',
667 'label' => $this->
msg(
'namespace' )->text(),
668 'name' =>
'namespace',
669 'cssclass' =>
'namespaceselector',
672 'section' =>
'contribs-top',
675 $nsFilters = $request->getArray(
'wpfilters' );
676 $fields[
'nsFilters'] = [
677 'class' => HTMLMultiSelectField::class,
679 'name' =>
'wpfilters',
682 'cssclass' => $ns ===
'' ?
683 'contribs-ns-filters mw-input-with-label mw-input-hidden' :
684 'contribs-ns-filters mw-input-with-label',
688 'options-messages' => [
689 'invert' =>
'nsInvert',
690 'namespace_association' =>
'associated',
692 'default' => $nsFilters,
693 'section' =>
'contribs-top',
695 $fields[
'tagfilter'] = [
696 'type' =>
'tagfilter',
697 'cssclass' =>
'mw-tagfilter-input',
699 'label-message' => [
'tag-filter',
'parse' ],
700 'name' =>
'tagfilter',
702 'section' =>
'contribs-top',
705 if ( $this->permissionManager->userHasRight( $this->getUser(),
'deletedhistory' ) ) {
706 $fields[
'deletedOnly'] = [
708 'id' =>
'mw-show-deleted-only',
709 'label' => $this->
msg(
'history-show-deleted' )->text(),
710 'name' =>
'deletedOnly',
711 'section' =>
'contribs-top',
715 $fields[
'topOnly'] = [
717 'id' =>
'mw-show-top-only',
718 'label' => $this->
msg(
'sp-contributions-toponly' )->text(),
720 'section' =>
'contribs-top',
722 $fields[
'newOnly'] = [
724 'id' =>
'mw-show-new-only',
725 'label' => $this->
msg(
'sp-contributions-newonly' )->text(),
727 'section' =>
'contribs-top',
729 $fields[
'hideMinor'] = [
731 'cssclass' =>
'mw-hide-minor-edits',
732 'id' =>
'mw-show-new-only',
733 'label' => $this->
msg(
'sp-contributions-hideminor' )->text(),
734 'name' =>
'hideMinor',
735 'section' =>
'contribs-top',
740 $this->
getHookRunner()->onSpecialContributions__getForm__filters(
741 $this, $rawFilters );
742 foreach ( $rawFilters as $filter ) {
744 if ( is_string( $filter ) ) {
747 'default' => $filter,
749 'section' =>
'contribs-top',
752 'A SpecialContributions::getForm::filters hook handler returned ' .
753 'an array of strings, this is deprecated since MediaWiki 1.33',
765 'id' =>
'mw-date-start',
766 'label' => $this->
msg(
'date-range-from' )->text(),
768 'section' =>
'contribs-date',
773 'id' =>
'mw-date-end',
774 'label' => $this->
msg(
'date-range-to' )->text(),
776 'section' =>
'contribs-date',
785 ->setCollapsibleOptions(
786 ( $pagerOptions[
'target'] ??
null ) ||
787 ( $pagerOptions[
'start'] ??
null ) ||
788 ( $pagerOptions[
'end'] ??
null )
791 ->setSubmitText( $this->
msg(
'sp-contributions-submit' )->text() )
792 ->setWrapperLegend( $this->
msg(
'sp-contributions-search' )->text() );
794 $explain = $this->
msg(
'sp-contributions-explain' );
795 if ( !$explain->isBlank() ) {
796 $htmlForm->addFooterText(
"<p id='mw-sp-contributions-explain'>{$explain->parse()}</p>" );
799 $htmlForm->loadData();
801 return $htmlForm->getHTML(
false );
813 $search = $this->userNameUtils->getCanonical( $search );
819 return $this->userNamePrefixSearch
820 ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset );
824 if ( $this->pager ===
null ) {
826 'target' => $this->opts[
'target'],
827 'namespace' => $this->opts[
'namespace'],
828 'tagfilter' => $this->opts[
'tagfilter'],
829 'start' => $this->opts[
'start'] ??
'',
830 'end' => $this->opts[
'end'] ??
'',
831 'deletedOnly' => $this->opts[
'deletedOnly'],
832 'topOnly' => $this->opts[
'topOnly'],
833 'newOnly' => $this->opts[
'newOnly'],
834 'hideMinor' => $this->opts[
'hideMinor'],
835 'nsInvert' => $this->opts[
'nsInvert'],
836 'associated' => $this->opts[
'associated'],