Merge "Add an audio file parser test"
[lhc/web/wiklou.git] / includes / specials / SpecialContributions.php
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
24 /**
25 * Special:Contributions, show user contributions in a paged list
26 *
27 * @ingroup SpecialPage
28 */
29 class SpecialContributions extends IncludableSpecialPage {
30 protected $opts;
31
32 public function __construct() {
33 parent::__construct( 'Contributions' );
34 }
35
36 public function execute( $par ) {
37 $this->setHeaders();
38 $this->outputHeader();
39 $out = $this->getOutput();
40 $out->addModuleStyles( 'mediawiki.special' );
41 $this->addHelpLink( 'Help:User contributions' );
42
43 $this->opts = [];
44 $request = $this->getRequest();
45
46 if ( $par !== null ) {
47 $target = $par;
48 } else {
49 $target = $request->getVal( 'target' );
50 }
51
52 if ( $request->getVal( 'contribs' ) == 'newbie' || $par === 'newbies' ) {
53 $target = 'newbies';
54 $this->opts['contribs'] = 'newbie';
55 } else {
56 $this->opts['contribs'] = 'user';
57 }
58
59 $this->opts['deletedOnly'] = $request->getBool( 'deletedOnly' );
60
61 if ( !strlen( $target ) ) {
62 if ( !$this->including() ) {
63 $out->addHTML( $this->getForm() );
64 }
65
66 return;
67 }
68
69 $user = $this->getUser();
70
71 $this->opts['limit'] = $request->getInt( 'limit', $user->getOption( 'rclimit' ) );
72 $this->opts['target'] = $target;
73 $this->opts['topOnly'] = $request->getBool( 'topOnly' );
74 $this->opts['newOnly'] = $request->getBool( 'newOnly' );
75 $this->opts['hideMinor'] = $request->getBool( 'hideMinor' );
76
77 $nt = Title::makeTitleSafe( NS_USER, $target );
78 if ( !$nt ) {
79 $out->addHTML( $this->getForm() );
80
81 return;
82 }
83 $userObj = User::newFromName( $nt->getText(), false );
84 if ( !$userObj ) {
85 $out->addHTML( $this->getForm() );
86
87 return;
88 }
89 $id = $userObj->getId();
90
91 if ( $this->opts['contribs'] != 'newbie' ) {
92 $target = $nt->getText();
93 $out->addSubtitle( $this->contributionsSub( $userObj ) );
94 $out->setHTMLTitle( $this->msg(
95 'pagetitle',
96 $this->msg( 'contributions-title', $target )->plain()
97 )->inContentLanguage() );
98 $this->getSkin()->setRelevantUser( $userObj );
99 } else {
100 $out->addSubtitle( $this->msg( 'sp-contributions-newbies-sub' ) );
101 $out->setHTMLTitle( $this->msg(
102 'pagetitle',
103 $this->msg( 'sp-contributions-newbies-title' )->plain()
104 )->inContentLanguage() );
105 }
106
107 $ns = $request->getVal( 'namespace', null );
108 if ( $ns !== null && $ns !== '' ) {
109 $this->opts['namespace'] = intval( $ns );
110 } else {
111 $this->opts['namespace'] = '';
112 }
113
114 $this->opts['associated'] = $request->getBool( 'associated' );
115 $this->opts['nsInvert'] = (bool)$request->getVal( 'nsInvert' );
116 $this->opts['tagfilter'] = (string)$request->getVal( 'tagfilter' );
117
118 // Allows reverts to have the bot flag in recent changes. It is just here to
119 // be passed in the form at the top of the page
120 if ( $user->isAllowed( 'markbotedits' ) && $request->getBool( 'bot' ) ) {
121 $this->opts['bot'] = '1';
122 }
123
124 $skip = $request->getText( 'offset' ) || $request->getText( 'dir' ) == 'prev';
125 # Offset overrides year/month selection
126 if ( $skip ) {
127 $this->opts['year'] = '';
128 $this->opts['month'] = '';
129 } else {
130 $this->opts['year'] = $request->getIntOrNull( 'year' );
131 $this->opts['month'] = $request->getIntOrNull( 'month' );
132 }
133
134 $feedType = $request->getVal( 'feed' );
135
136 $feedParams = [
137 'action' => 'feedcontributions',
138 'user' => $target,
139 ];
140 if ( $this->opts['topOnly'] ) {
141 $feedParams['toponly'] = true;
142 }
143 if ( $this->opts['newOnly'] ) {
144 $feedParams['newonly'] = true;
145 }
146 if ( $this->opts['hideMinor'] ) {
147 $feedParams['hideminor'] = true;
148 }
149 if ( $this->opts['deletedOnly'] ) {
150 $feedParams['deletedonly'] = true;
151 }
152 if ( $this->opts['tagfilter'] !== '' ) {
153 $feedParams['tagfilter'] = $this->opts['tagfilter'];
154 }
155 if ( $this->opts['namespace'] !== '' ) {
156 $feedParams['namespace'] = $this->opts['namespace'];
157 }
158 // Don't use year and month for the feed URL, but pass them on if
159 // we redirect to API (if $feedType is specified)
160 if ( $feedType && $this->opts['year'] !== null ) {
161 $feedParams['year'] = $this->opts['year'];
162 }
163 if ( $feedType && $this->opts['month'] !== null ) {
164 $feedParams['month'] = $this->opts['month'];
165 }
166
167 if ( $feedType ) {
168 // Maintain some level of backwards compatibility
169 // If people request feeds using the old parameters, redirect to API
170 $feedParams['feedformat'] = $feedType;
171 $url = wfAppendQuery( wfScript( 'api' ), $feedParams );
172
173 $out->redirect( $url, '301' );
174
175 return;
176 }
177
178 // Add RSS/atom links
179 $this->addFeedLinks( $feedParams );
180
181 if ( Hooks::run( 'SpecialContributionsBeforeMainOutput', [ $id, $userObj, $this ] ) ) {
182 if ( !$this->including() ) {
183 $out->addHTML( $this->getForm() );
184 }
185 $pager = new ContribsPager( $this->getContext(), [
186 'target' => $target,
187 'contribs' => $this->opts['contribs'],
188 'namespace' => $this->opts['namespace'],
189 'tagfilter' => $this->opts['tagfilter'],
190 'year' => $this->opts['year'],
191 'month' => $this->opts['month'],
192 'deletedOnly' => $this->opts['deletedOnly'],
193 'topOnly' => $this->opts['topOnly'],
194 'newOnly' => $this->opts['newOnly'],
195 'hideMinor' => $this->opts['hideMinor'],
196 'nsInvert' => $this->opts['nsInvert'],
197 'associated' => $this->opts['associated'],
198 ] );
199
200 if ( !$pager->getNumRows() ) {
201 $out->addWikiMsg( 'nocontribs', $target );
202 } else {
203 # Show a message about slave lag, if applicable
204 $lag = wfGetLB()->safeGetLag( $pager->getDatabase() );
205 if ( $lag > 0 ) {
206 $out->showLagWarning( $lag );
207 }
208
209 $output = $pager->getBody();
210 if ( !$this->including() ) {
211 $output = '<p>' . $pager->getNavigationBar() . '</p>' .
212 $output .
213 '<p>' . $pager->getNavigationBar() . '</p>';
214 }
215 $out->addHTML( $output );
216 }
217 $out->preventClickjacking( $pager->getPreventClickjacking() );
218
219 # Show the appropriate "footer" message - WHOIS tools, etc.
220 if ( $this->opts['contribs'] == 'newbie' ) {
221 $message = 'sp-contributions-footer-newbies';
222 } elseif ( IP::isIPAddress( $target ) ) {
223 $message = 'sp-contributions-footer-anon';
224 } elseif ( $userObj->isAnon() ) {
225 // No message for non-existing users
226 $message = '';
227 } else {
228 $message = 'sp-contributions-footer';
229 }
230
231 if ( $message ) {
232 if ( !$this->including() ) {
233 if ( !$this->msg( $message, $target )->isDisabled() ) {
234 $out->wrapWikiMsg(
235 "<div class='mw-contributions-footer'>\n$1\n</div>",
236 [ $message, $target ] );
237 }
238 }
239 }
240 }
241 }
242
243 /**
244 * Generates the subheading with links
245 * @param User $userObj User object for the target
246 * @return string Appropriately-escaped HTML to be output literally
247 * @todo FIXME: Almost the same as getSubTitle in SpecialDeletedContributions.php.
248 * Could be combined.
249 */
250 protected function contributionsSub( $userObj ) {
251 if ( $userObj->isAnon() ) {
252 // Show a warning message that the user being searched for doesn't exists
253 if ( !User::isIP( $userObj->getName() ) ) {
254 $this->getOutput()->wrapWikiMsg(
255 "<div class=\"mw-userpage-userdoesnotexist error\">\n\$1\n</div>",
256 [
257 'contributions-userdoesnotexist',
258 wfEscapeWikiText( $userObj->getName() ),
259 ]
260 );
261 if ( !$this->including() ) {
262 $this->getOutput()->setStatusCode( 404 );
263 }
264 }
265 $user = htmlspecialchars( $userObj->getName() );
266 } else {
267 $user = Linker::link( $userObj->getUserPage(), htmlspecialchars( $userObj->getName() ) );
268 }
269 $nt = $userObj->getUserPage();
270 $talk = $userObj->getTalkPage();
271 $links = '';
272 if ( $talk ) {
273 $tools = $this->getUserLinks( $nt, $talk, $userObj );
274 $links = $this->getLanguage()->pipeList( $tools );
275
276 // Show a note if the user is blocked and display the last block log entry.
277 // Do not expose the autoblocks, since that may lead to a leak of accounts' IPs,
278 // and also this will display a totally irrelevant log entry as a current block.
279 if ( !$this->including() ) {
280 $block = Block::newFromTarget( $userObj, $userObj );
281 if ( !is_null( $block ) && $block->getType() != Block::TYPE_AUTO ) {
282 if ( $block->getType() == Block::TYPE_RANGE ) {
283 $nt = MWNamespace::getCanonicalName( NS_USER ) . ':' . $block->getTarget();
284 }
285
286 $out = $this->getOutput(); // showLogExtract() wants first parameter by reference
287 LogEventsList::showLogExtract(
288 $out,
289 'block',
290 $nt,
291 '',
292 [
293 'lim' => 1,
294 'showIfEmpty' => false,
295 'msgKey' => [
296 $userObj->isAnon() ?
297 'sp-contributions-blocked-notice-anon' :
298 'sp-contributions-blocked-notice',
299 $userObj->getName() # Support GENDER in 'sp-contributions-blocked-notice'
300 ],
301 'offset' => '' # don't use WebRequest parameter offset
302 ]
303 );
304 }
305 }
306 }
307
308 return $this->msg( 'contribsub2' )->rawParams( $user, $links )->params( $userObj->getName() );
309 }
310
311 /**
312 * Links to different places.
313 * @param Title $userpage Target user page
314 * @param Title $talkpage Talk page
315 * @param User $target Target user object
316 * @return array
317 */
318 public function getUserLinks( Title $userpage, Title $talkpage, User $target ) {
319
320 $id = $target->getId();
321 $username = $target->getName();
322
323 $tools[] = Linker::link( $talkpage, $this->msg( 'sp-contributions-talk' )->escaped() );
324
325 if ( ( $id !== null ) || ( $id === null && IP::isIPAddress( $username ) ) ) {
326 if ( $this->getUser()->isAllowed( 'block' ) ) { # Block / Change block / Unblock links
327 if ( $target->isBlocked() && $target->getBlock()->getType() != Block::TYPE_AUTO ) {
328 $tools[] = Linker::linkKnown( # Change block link
329 SpecialPage::getTitleFor( 'Block', $username ),
330 $this->msg( 'change-blocklink' )->escaped()
331 );
332 $tools[] = Linker::linkKnown( # Unblock link
333 SpecialPage::getTitleFor( 'Unblock', $username ),
334 $this->msg( 'unblocklink' )->escaped()
335 );
336 } else { # User is not blocked
337 $tools[] = Linker::linkKnown( # Block link
338 SpecialPage::getTitleFor( 'Block', $username ),
339 $this->msg( 'blocklink' )->escaped()
340 );
341 }
342 }
343
344 # Block log link
345 $tools[] = Linker::linkKnown(
346 SpecialPage::getTitleFor( 'Log', 'block' ),
347 $this->msg( 'sp-contributions-blocklog' )->escaped(),
348 [],
349 [ 'page' => $userpage->getPrefixedText() ]
350 );
351
352 # Suppression log link (bug 59120)
353 if ( $this->getUser()->isAllowed( 'suppressionlog' ) ) {
354 $tools[] = Linker::linkKnown(
355 SpecialPage::getTitleFor( 'Log', 'suppress' ),
356 $this->msg( 'sp-contributions-suppresslog' )->escaped(),
357 [],
358 [ 'offender' => $username ]
359 );
360 }
361 }
362 # Uploads
363 $tools[] = Linker::linkKnown(
364 SpecialPage::getTitleFor( 'Listfiles', $username ),
365 $this->msg( 'sp-contributions-uploads' )->escaped()
366 );
367
368 # Other logs link
369 $tools[] = Linker::linkKnown(
370 SpecialPage::getTitleFor( 'Log', $username ),
371 $this->msg( 'sp-contributions-logs' )->escaped()
372 );
373
374 # Add link to deleted user contributions for priviledged users
375 if ( $this->getUser()->isAllowed( 'deletedhistory' ) ) {
376 $tools[] = Linker::linkKnown(
377 SpecialPage::getTitleFor( 'DeletedContributions', $username ),
378 $this->msg( 'sp-contributions-deleted' )->escaped()
379 );
380 }
381
382 # Add a link to change user rights for privileged users
383 $userrightsPage = new UserrightsPage();
384 $userrightsPage->setContext( $this->getContext() );
385 if ( $userrightsPage->userCanChangeRights( $target ) ) {
386 $tools[] = Linker::linkKnown(
387 SpecialPage::getTitleFor( 'Userrights', $username ),
388 $this->msg( 'sp-contributions-userrights' )->escaped()
389 );
390 }
391
392 Hooks::run( 'ContributionsToolLinks', [ $id, $userpage, &$tools ] );
393
394 return $tools;
395 }
396
397 /**
398 * Generates the namespace selector form with hidden attributes.
399 * @return string HTML fragment
400 */
401 protected function getForm() {
402 $this->opts['title'] = $this->getPageTitle()->getPrefixedText();
403 if ( !isset( $this->opts['target'] ) ) {
404 $this->opts['target'] = '';
405 } else {
406 $this->opts['target'] = str_replace( '_', ' ', $this->opts['target'] );
407 }
408
409 if ( !isset( $this->opts['namespace'] ) ) {
410 $this->opts['namespace'] = '';
411 }
412
413 if ( !isset( $this->opts['nsInvert'] ) ) {
414 $this->opts['nsInvert'] = '';
415 }
416
417 if ( !isset( $this->opts['associated'] ) ) {
418 $this->opts['associated'] = false;
419 }
420
421 if ( !isset( $this->opts['contribs'] ) ) {
422 $this->opts['contribs'] = 'user';
423 }
424
425 if ( !isset( $this->opts['year'] ) ) {
426 $this->opts['year'] = '';
427 }
428
429 if ( !isset( $this->opts['month'] ) ) {
430 $this->opts['month'] = '';
431 }
432
433 if ( $this->opts['contribs'] == 'newbie' ) {
434 $this->opts['target'] = '';
435 }
436
437 if ( !isset( $this->opts['tagfilter'] ) ) {
438 $this->opts['tagfilter'] = '';
439 }
440
441 if ( !isset( $this->opts['topOnly'] ) ) {
442 $this->opts['topOnly'] = false;
443 }
444
445 if ( !isset( $this->opts['newOnly'] ) ) {
446 $this->opts['newOnly'] = false;
447 }
448
449 if ( !isset( $this->opts['hideMinor'] ) ) {
450 $this->opts['hideMinor'] = false;
451 }
452
453 $form = Html::openElement(
454 'form',
455 [
456 'method' => 'get',
457 'action' => wfScript(),
458 'class' => 'mw-contributions-form'
459 ]
460 );
461
462 # Add hidden params for tracking except for parameters in $skipParameters
463 $skipParameters = [
464 'namespace',
465 'nsInvert',
466 'deletedOnly',
467 'target',
468 'contribs',
469 'year',
470 'month',
471 'topOnly',
472 'newOnly',
473 'hideMinor',
474 'associated',
475 'tagfilter'
476 ];
477
478 foreach ( $this->opts as $name => $value ) {
479 if ( in_array( $name, $skipParameters ) ) {
480 continue;
481 }
482 $form .= "\t" . Html::hidden( $name, $value ) . "\n";
483 }
484
485 $tagFilter = ChangeTags::buildTagFilterSelector( $this->opts['tagfilter'] );
486
487 if ( $tagFilter ) {
488 $filterSelection = Html::rawElement(
489 'td',
490 [],
491 implode( '&#160;', $tagFilter )
492 );
493 } else {
494 $filterSelection = Html::rawElement( 'td', [ 'colspan' => 2 ], '' );
495 }
496
497 $this->getOutput()->addModules( 'mediawiki.userSuggest' );
498
499 $labelNewbies = Xml::radioLabel(
500 $this->msg( 'sp-contributions-newbies' )->text(),
501 'contribs',
502 'newbie',
503 'newbie',
504 $this->opts['contribs'] == 'newbie',
505 [ 'class' => 'mw-input' ]
506 );
507 $labelUsername = Xml::radioLabel(
508 $this->msg( 'sp-contributions-username' )->text(),
509 'contribs',
510 'user',
511 'user',
512 $this->opts['contribs'] == 'user',
513 [ 'class' => 'mw-input' ]
514 );
515 $input = Html::input(
516 'target',
517 $this->opts['target'],
518 'text',
519 [
520 'size' => '40',
521 'required' => '',
522 'class' => [
523 'mw-input',
524 'mw-ui-input-inline',
525 'mw-autocomplete-user', // used by mediawiki.userSuggest
526 ],
527 ] + (
528 // Only autofocus if target hasn't been specified or in non-newbies mode
529 ( $this->opts['contribs'] === 'newbie' || $this->opts['target'] )
530 ? [] : [ 'autofocus' => true ]
531 )
532 );
533
534 $targetSelection = Html::rawElement(
535 'td',
536 [ 'colspan' => 2 ],
537 $labelNewbies . '<br />' . $labelUsername . ' ' . $input . ' '
538 );
539
540 $namespaceSelection = Xml::tags(
541 'td',
542 [],
543 Xml::label(
544 $this->msg( 'namespace' )->text(),
545 'namespace',
546 ''
547 ) .
548 Html::namespaceSelector(
549 [ 'selected' => $this->opts['namespace'], 'all' => '' ],
550 [
551 'name' => 'namespace',
552 'id' => 'namespace',
553 'class' => 'namespaceselector',
554 ]
555 ) . '&#160;' .
556 Html::rawElement(
557 'span',
558 [ 'class' => 'mw-input-with-label' ],
559 Xml::checkLabel(
560 $this->msg( 'invert' )->text(),
561 'nsInvert',
562 'nsInvert',
563 $this->opts['nsInvert'],
564 [
565 'title' => $this->msg( 'tooltip-invert' )->text(),
566 'class' => 'mw-input'
567 ]
568 ) . '&#160;'
569 ) .
570 Html::rawElement( 'span', [ 'class' => 'mw-input-with-label' ],
571 Xml::checkLabel(
572 $this->msg( 'namespace_association' )->text(),
573 'associated',
574 'associated',
575 $this->opts['associated'],
576 [
577 'title' => $this->msg( 'tooltip-namespace_association' )->text(),
578 'class' => 'mw-input'
579 ]
580 ) . '&#160;'
581 )
582 );
583
584 $filters = [];
585
586 if ( $this->getUser()->isAllowed( 'deletedhistory' ) ) {
587 $filters[] = Html::rawElement(
588 'span',
589 [ 'class' => 'mw-input-with-label' ],
590 Xml::checkLabel(
591 $this->msg( 'history-show-deleted' )->text(),
592 'deletedOnly',
593 'mw-show-deleted-only',
594 $this->opts['deletedOnly'],
595 [ 'class' => 'mw-input' ]
596 )
597 );
598 }
599
600 $filters[] = Html::rawElement(
601 'span',
602 [ 'class' => 'mw-input-with-label' ],
603 Xml::checkLabel(
604 $this->msg( 'sp-contributions-toponly' )->text(),
605 'topOnly',
606 'mw-show-top-only',
607 $this->opts['topOnly'],
608 [ 'class' => 'mw-input' ]
609 )
610 );
611 $filters[] = Html::rawElement(
612 'span',
613 [ 'class' => 'mw-input-with-label' ],
614 Xml::checkLabel(
615 $this->msg( 'sp-contributions-newonly' )->text(),
616 'newOnly',
617 'mw-show-new-only',
618 $this->opts['newOnly'],
619 [ 'class' => 'mw-input' ]
620 )
621 );
622 $filters[] = Html::rawElement(
623 'span',
624 [ 'class' => 'mw-input-with-label' ],
625 Xml::checkLabel(
626 $this->msg( 'sp-contributions-hideminor' )->text(),
627 'hideMinor',
628 'mw-hide-minor-edits',
629 $this->opts['hideMinor'],
630 [ 'class' => 'mw-input' ]
631 )
632 );
633
634 Hooks::run(
635 'SpecialContributions::getForm::filters',
636 [ $this, &$filters ]
637 );
638
639 $extraOptions = Html::rawElement(
640 'td',
641 [ 'colspan' => 2 ],
642 implode( '', $filters )
643 );
644
645 $dateSelectionAndSubmit = Xml::tags( 'td', [ 'colspan' => 2 ],
646 Xml::dateMenu(
647 $this->opts['year'] === '' ? MWTimestamp::getInstance()->format( 'Y' ) : $this->opts['year'],
648 $this->opts['month']
649 ) . ' ' .
650 Html::submitButton(
651 $this->msg( 'sp-contributions-submit' )->text(),
652 [ 'class' => 'mw-submit' ], [ 'mw-ui-progressive' ]
653 )
654 );
655
656 $form .= Xml::fieldset( $this->msg( 'sp-contributions-search' )->text() );
657 $form .= Html::rawElement( 'table', [ 'class' => 'mw-contributions-table' ], "\n" .
658 Html::rawElement( 'tr', [], $targetSelection ) . "\n" .
659 Html::rawElement( 'tr', [], $namespaceSelection ) . "\n" .
660 Html::rawElement( 'tr', [], $filterSelection ) . "\n" .
661 Html::rawElement( 'tr', [], $extraOptions ) . "\n" .
662 Html::rawElement( 'tr', [], $dateSelectionAndSubmit ) . "\n"
663 );
664
665 $explain = $this->msg( 'sp-contributions-explain' );
666 if ( !$explain->isBlank() ) {
667 $form .= "<p id='mw-sp-contributions-explain'>{$explain->parse()}</p>";
668 }
669
670 $form .= Xml::closeElement( 'fieldset' ) . Xml::closeElement( 'form' );
671
672 return $form;
673 }
674
675 /**
676 * Return an array of subpages beginning with $search that this special page will accept.
677 *
678 * @param string $search Prefix to search for
679 * @param int $limit Maximum number of results to return (usually 10)
680 * @param int $offset Number of results to skip (usually 0)
681 * @return string[] Matching subpages
682 */
683 public function prefixSearchSubpages( $search, $limit, $offset ) {
684 $user = User::newFromName( $search );
685 if ( !$user ) {
686 // No prefix suggestion for invalid user
687 return [];
688 }
689 // Autocomplete subpage as user list - public to allow caching
690 return UserNamePrefixSearch::search( 'public', $search, $limit, $offset );
691 }
692
693 protected function getGroupName() {
694 return 'users';
695 }
696 }