Merge "Use HTMLForm for Special:FileDuplicateSearch"
[lhc/web/wiklou.git] / includes / specials / SpecialDeletedContributions.php
1 <?php
2 /**
3 * Implements Special:DeletedContributions
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 * Implements Special:DeletedContributions to display archived revisions
26 * @ingroup SpecialPage
27 */
28 class DeletedContribsPager extends IndexPager {
29 public $mDefaultDirection = IndexPager::DIR_DESCENDING;
30 public $messages;
31 public $target;
32 public $namespace = '';
33 public $mDb;
34
35 /**
36 * @var string Navigation bar with paging links.
37 */
38 protected $mNavigationBar;
39
40 function __construct( IContextSource $context, $target, $namespace = false ) {
41 parent::__construct( $context );
42 $msgs = array( 'deletionlog', 'undeleteviewlink', 'diff' );
43 foreach ( $msgs as $msg ) {
44 $this->messages[$msg] = $this->msg( $msg )->escaped();
45 }
46 $this->target = $target;
47 $this->namespace = $namespace;
48 $this->mDb = wfGetDB( DB_SLAVE, 'contributions' );
49 }
50
51 function getDefaultQuery() {
52 $query = parent::getDefaultQuery();
53 $query['target'] = $this->target;
54
55 return $query;
56 }
57
58 function getQueryInfo() {
59 list( $index, $userCond ) = $this->getUserCond();
60 $conds = array_merge( $userCond, $this->getNamespaceCond() );
61 $user = $this->getUser();
62 // Paranoia: avoid brute force searches (bug 17792)
63 if ( !$user->isAllowed( 'deletedhistory' ) ) {
64 $conds[] = $this->mDb->bitAnd( 'ar_deleted', Revision::DELETED_USER ) . ' = 0';
65 } elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
66 $conds[] = $this->mDb->bitAnd( 'ar_deleted', Revision::SUPPRESSED_USER ) .
67 ' != ' . Revision::SUPPRESSED_USER;
68 }
69
70 return array(
71 'tables' => array( 'archive' ),
72 'fields' => array(
73 'ar_rev_id', 'ar_namespace', 'ar_title', 'ar_timestamp', 'ar_comment',
74 'ar_minor_edit', 'ar_user', 'ar_user_text', 'ar_deleted'
75 ),
76 'conds' => $conds,
77 'options' => array( 'USE INDEX' => $index )
78 );
79 }
80
81 /**
82 * This method basically executes the exact same code as the parent class, though with
83 * a hook added, to allow extensions to add additional queries.
84 *
85 * @param string $offset Index offset, inclusive
86 * @param int $limit Exact query limit
87 * @param bool $descending Query direction, false for ascending, true for descending
88 * @return ResultWrapper
89 */
90 function reallyDoQuery( $offset, $limit, $descending ) {
91 $pager = $this;
92
93 $data = array( parent::reallyDoQuery( $offset, $limit, $descending ) );
94
95 // This hook will allow extensions to add in additional queries, nearly
96 // identical to ContribsPager::reallyDoQuery.
97 Hooks::run(
98 'DeletedContribsPager::reallyDoQuery',
99 array( &$data, $pager, $offset, $limit, $descending )
100 );
101
102 $result = array();
103
104 // loop all results and collect them in an array
105 foreach ( $data as $query ) {
106 foreach ( $query as $i => $row ) {
107 // use index column as key, allowing us to easily sort in PHP
108 $result[$row->{$this->getIndexField()} . "-$i"] = $row;
109 }
110 }
111
112 // sort results
113 if ( $descending ) {
114 ksort( $result );
115 } else {
116 krsort( $result );
117 }
118
119 // enforce limit
120 $result = array_slice( $result, 0, $limit );
121
122 // get rid of array keys
123 $result = array_values( $result );
124
125 return new FakeResultWrapper( $result );
126 }
127
128 function getUserCond() {
129 $condition = array();
130
131 $condition['ar_user_text'] = $this->target;
132 $index = 'usertext_timestamp';
133
134 return array( $index, $condition );
135 }
136
137 function getIndexField() {
138 return 'ar_timestamp';
139 }
140
141 function getStartBody() {
142 return "<ul>\n";
143 }
144
145 function getEndBody() {
146 return "</ul>\n";
147 }
148
149 function getNavigationBar() {
150 if ( isset( $this->mNavigationBar ) ) {
151 return $this->mNavigationBar;
152 }
153
154 $linkTexts = array(
155 'prev' => $this->msg( 'pager-newer-n' )->numParams( $this->mLimit )->escaped(),
156 'next' => $this->msg( 'pager-older-n' )->numParams( $this->mLimit )->escaped(),
157 'first' => $this->msg( 'histlast' )->escaped(),
158 'last' => $this->msg( 'histfirst' )->escaped()
159 );
160
161 $pagingLinks = $this->getPagingLinks( $linkTexts );
162 $limitLinks = $this->getLimitLinks();
163 $lang = $this->getLanguage();
164 $limits = $lang->pipeList( $limitLinks );
165
166 $firstLast = $lang->pipeList( array( $pagingLinks['first'], $pagingLinks['last'] ) );
167 $firstLast = $this->msg( 'parentheses' )->rawParams( $firstLast )->escaped();
168 $prevNext = $this->msg( 'viewprevnext' )
169 ->rawParams(
170 $pagingLinks['prev'],
171 $pagingLinks['next'],
172 $limits
173 )->escaped();
174 $separator = $this->msg( 'word-separator' )->escaped();
175 $this->mNavigationBar = $firstLast . $separator . $prevNext;
176
177 return $this->mNavigationBar;
178 }
179
180 function getNamespaceCond() {
181 if ( $this->namespace !== '' ) {
182 return array( 'ar_namespace' => (int)$this->namespace );
183 } else {
184 return array();
185 }
186 }
187
188 /**
189 * Generates each row in the contributions list.
190 *
191 * @todo This would probably look a lot nicer in a table.
192 * @param stdClass $row
193 * @return string
194 */
195 function formatRow( $row ) {
196 $ret = '';
197 $classes = array();
198
199 /*
200 * There may be more than just revision rows. To make sure that we'll only be processing
201 * revisions here, let's _try_ to build a revision out of our row (without displaying
202 * notices though) and then trying to grab data from the built object. If we succeed,
203 * we're definitely dealing with revision data and we may proceed, if not, we'll leave it
204 * to extensions to subscribe to the hook to parse the row.
205 */
206 wfSuppressWarnings();
207 try {
208 $rev = Revision::newFromArchiveRow( $row );
209 $validRevision = (bool)$rev->getId();
210 } catch ( Exception $e ) {
211 $validRevision = false;
212 }
213 wfRestoreWarnings();
214
215 if ( $validRevision ) {
216 $ret = $this->formatRevisionRow( $row );
217 }
218
219 // Let extensions add data
220 Hooks::run( 'DeletedContributionsLineEnding', array( $this, &$ret, $row, &$classes ) );
221
222 if ( $classes === array() && $ret === '' ) {
223 wfDebug( "Dropping Special:DeletedContribution row that could not be formatted\n" );
224 $ret = "<!-- Could not format Special:DeletedContribution row. -->\n";
225 } else {
226 $ret = Html::rawElement( 'li', array( 'class' => $classes ), $ret ) . "\n";
227 }
228
229 return $ret;
230 }
231
232 /**
233 * Generates each row in the contributions list for archive entries.
234 *
235 * Contributions which are marked "top" are currently on top of the history.
236 * For these contributions, a [rollback] link is shown for users with sysop
237 * privileges. The rollback link restores the most recent version that was not
238 * written by the target user.
239 *
240 * @todo This would probably look a lot nicer in a table.
241 * @param stdClass $row
242 * @return string
243 */
244 function formatRevisionRow( $row ) {
245 $page = Title::makeTitle( $row->ar_namespace, $row->ar_title );
246
247 $rev = new Revision( array(
248 'title' => $page,
249 'id' => $row->ar_rev_id,
250 'comment' => $row->ar_comment,
251 'user' => $row->ar_user,
252 'user_text' => $row->ar_user_text,
253 'timestamp' => $row->ar_timestamp,
254 'minor_edit' => $row->ar_minor_edit,
255 'deleted' => $row->ar_deleted,
256 ) );
257
258 $undelete = SpecialPage::getTitleFor( 'Undelete' );
259
260 $logs = SpecialPage::getTitleFor( 'Log' );
261 $dellog = Linker::linkKnown(
262 $logs,
263 $this->messages['deletionlog'],
264 array(),
265 array(
266 'type' => 'delete',
267 'page' => $page->getPrefixedText()
268 )
269 );
270
271 $reviewlink = Linker::linkKnown(
272 SpecialPage::getTitleFor( 'Undelete', $page->getPrefixedDBkey() ),
273 $this->messages['undeleteviewlink']
274 );
275
276 $user = $this->getUser();
277
278 if ( $user->isAllowed( 'deletedtext' ) ) {
279 $last = Linker::linkKnown(
280 $undelete,
281 $this->messages['diff'],
282 array(),
283 array(
284 'target' => $page->getPrefixedText(),
285 'timestamp' => $rev->getTimestamp(),
286 'diff' => 'prev'
287 )
288 );
289 } else {
290 $last = $this->messages['diff'];
291 }
292
293 $comment = Linker::revComment( $rev );
294 $date = $this->getLanguage()->userTimeAndDate( $rev->getTimestamp(), $user );
295 $date = htmlspecialchars( $date );
296
297 if ( !$user->isAllowed( 'undelete' ) || !$rev->userCan( Revision::DELETED_TEXT, $user ) ) {
298 $link = $date; // unusable link
299 } else {
300 $link = Linker::linkKnown(
301 $undelete,
302 $date,
303 array( 'class' => 'mw-changeslist-date' ),
304 array(
305 'target' => $page->getPrefixedText(),
306 'timestamp' => $rev->getTimestamp()
307 )
308 );
309 }
310 // Style deleted items
311 if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
312 $link = '<span class="history-deleted">' . $link . '</span>';
313 }
314
315 $pagelink = Linker::link(
316 $page,
317 null,
318 array( 'class' => 'mw-changeslist-title' )
319 );
320
321 if ( $rev->isMinor() ) {
322 $mflag = ChangesList::flag( 'minor' );
323 } else {
324 $mflag = '';
325 }
326
327 // Revision delete link
328 $del = Linker::getRevDeleteLink( $user, $rev, $page );
329 if ( $del ) {
330 $del .= ' ';
331 }
332
333 $tools = Html::rawElement(
334 'span',
335 array( 'class' => 'mw-deletedcontribs-tools' ),
336 $this->msg( 'parentheses' )->rawParams( $this->getLanguage()->pipeList(
337 array( $last, $dellog, $reviewlink ) ) )->escaped()
338 );
339
340 $separator = '<span class="mw-changeslist-separator">. .</span>';
341 $ret = "{$del}{$link} {$tools} {$separator} {$mflag} {$pagelink} {$comment}";
342
343 # Denote if username is redacted for this edit
344 if ( $rev->isDeleted( Revision::DELETED_USER ) ) {
345 $ret .= " <strong>" . $this->msg( 'rev-deleted-user-contribs' )->escaped() . "</strong>";
346 }
347
348 return $ret;
349 }
350
351 /**
352 * Get the Database object in use
353 *
354 * @return IDatabase
355 */
356 public function getDatabase() {
357 return $this->mDb;
358 }
359 }
360
361 class DeletedContributionsPage extends SpecialPage {
362 function __construct() {
363 parent::__construct( 'DeletedContributions', 'deletedhistory',
364 /*listed*/true, /*function*/false, /*file*/false );
365 }
366
367 /**
368 * Special page "deleted user contributions".
369 * Shows a list of the deleted contributions of a user.
370 *
371 * @param string $par (optional) user name of the user for which to show the contributions
372 */
373 function execute( $par ) {
374 $this->setHeaders();
375 $this->outputHeader();
376
377 $user = $this->getUser();
378
379 if ( !$this->userCanExecute( $user ) ) {
380 $this->displayRestrictionError();
381
382 return;
383 }
384
385 $request = $this->getRequest();
386 $out = $this->getOutput();
387 $out->setPageTitle( $this->msg( 'deletedcontributions-title' ) );
388
389 $options = array();
390
391 if ( $par !== null ) {
392 $target = $par;
393 } else {
394 $target = $request->getVal( 'target' );
395 }
396
397 if ( !strlen( $target ) ) {
398 $out->addHTML( $this->getForm( '' ) );
399
400 return;
401 }
402
403 $options['limit'] = $request->getInt( 'limit',
404 $this->getConfig()->get( 'QueryPageDefaultLimit' ) );
405 $options['target'] = $target;
406
407 $userObj = User::newFromName( $target, false );
408 if ( !$userObj ) {
409 $out->addHTML( $this->getForm( '' ) );
410
411 return;
412 }
413 $this->getSkin()->setRelevantUser( $userObj );
414
415 $target = $userObj->getName();
416 $out->addSubtitle( $this->getSubTitle( $userObj ) );
417
418 if ( ( $ns = $request->getVal( 'namespace', null ) ) !== null && $ns !== '' ) {
419 $options['namespace'] = intval( $ns );
420 } else {
421 $options['namespace'] = '';
422 }
423
424 $out->addHTML( $this->getForm( $options ) );
425
426 $pager = new DeletedContribsPager( $this->getContext(), $target, $options['namespace'] );
427 if ( !$pager->getNumRows() ) {
428 $out->addWikiMsg( 'nocontribs' );
429
430 return;
431 }
432
433 # Show a message about slave lag, if applicable
434 $lag = wfGetLB()->safeGetLag( $pager->getDatabase() );
435 if ( $lag > 0 ) {
436 $out->showLagWarning( $lag );
437 }
438
439 $out->addHTML(
440 '<p>' . $pager->getNavigationBar() . '</p>' .
441 $pager->getBody() .
442 '<p>' . $pager->getNavigationBar() . '</p>' );
443
444 # If there were contributions, and it was a valid user or IP, show
445 # the appropriate "footer" message - WHOIS tools, etc.
446 if ( $target != 'newbies' ) {
447 $message = IP::isIPAddress( $target ) ?
448 'sp-contributions-footer-anon' :
449 'sp-contributions-footer';
450
451 if ( !$this->msg( $message )->isDisabled() ) {
452 $out->wrapWikiMsg(
453 "<div class='mw-contributions-footer'>\n$1\n</div>",
454 array( $message, $target )
455 );
456 }
457 }
458 }
459
460 /**
461 * Generates the subheading with links
462 * @param User $userObj User object for the target
463 * @return string Appropriately-escaped HTML to be output literally
464 * @todo FIXME: Almost the same as contributionsSub in SpecialContributions.php. Could be combined.
465 */
466 function getSubTitle( $userObj ) {
467 if ( $userObj->isAnon() ) {
468 $user = htmlspecialchars( $userObj->getName() );
469 } else {
470 $user = Linker::link( $userObj->getUserPage(), htmlspecialchars( $userObj->getName() ) );
471 }
472 $links = '';
473 $nt = $userObj->getUserPage();
474 $id = $userObj->getID();
475 $talk = $nt->getTalkPage();
476 if ( $talk ) {
477 # Talk page link
478 $tools[] = Linker::link( $talk, $this->msg( 'sp-contributions-talk' )->escaped() );
479 if ( ( $id !== null ) || ( $id === null && IP::isIPAddress( $nt->getText() ) ) ) {
480 # Block / Change block / Unblock links
481 if ( $this->getUser()->isAllowed( 'block' ) ) {
482 if ( $userObj->isBlocked() ) {
483 $tools[] = Linker::linkKnown( # Change block link
484 SpecialPage::getTitleFor( 'Block', $nt->getDBkey() ),
485 $this->msg( 'change-blocklink' )->escaped()
486 );
487 $tools[] = Linker::linkKnown( # Unblock link
488 SpecialPage::getTitleFor( 'BlockList' ),
489 $this->msg( 'unblocklink' )->escaped(),
490 array(),
491 array(
492 'action' => 'unblock',
493 'ip' => $nt->getDBkey()
494 )
495 );
496 } else {
497 # User is not blocked
498 $tools[] = Linker::linkKnown( # Block link
499 SpecialPage::getTitleFor( 'Block', $nt->getDBkey() ),
500 $this->msg( 'blocklink' )->escaped()
501 );
502 }
503 }
504 # Block log link
505 $tools[] = Linker::linkKnown(
506 SpecialPage::getTitleFor( 'Log' ),
507 $this->msg( 'sp-contributions-blocklog' )->escaped(),
508 array(),
509 array(
510 'type' => 'block',
511 'page' => $nt->getPrefixedText()
512 )
513 );
514 # Suppression log link (bug 59120)
515 if ( $this->getUser()->isAllowed( 'suppressionlog' ) ) {
516 $tools[] = Linker::linkKnown(
517 SpecialPage::getTitleFor( 'Log', 'suppress' ),
518 $this->msg( 'sp-contributions-suppresslog' )->escaped(),
519 array(),
520 array( 'offender' => $userObj->getName() )
521 );
522 }
523 }
524
525 # Uploads
526 $tools[] = Linker::linkKnown(
527 SpecialPage::getTitleFor( 'Listfiles', $userObj->getName() ),
528 $this->msg( 'sp-contributions-uploads' )->escaped()
529 );
530
531 # Other logs link
532 $tools[] = Linker::linkKnown(
533 SpecialPage::getTitleFor( 'Log' ),
534 $this->msg( 'sp-contributions-logs' )->escaped(),
535 array(),
536 array( 'user' => $nt->getText() )
537 );
538 # Link to contributions
539 $tools[] = Linker::linkKnown(
540 SpecialPage::getTitleFor( 'Contributions', $nt->getDBkey() ),
541 $this->msg( 'sp-deletedcontributions-contribs' )->escaped()
542 );
543
544 # Add a link to change user rights for privileged users
545 $userrightsPage = new UserrightsPage();
546 $userrightsPage->setContext( $this->getContext() );
547 if ( $userrightsPage->userCanChangeRights( $userObj ) ) {
548 $tools[] = Linker::linkKnown(
549 SpecialPage::getTitleFor( 'Userrights', $nt->getDBkey() ),
550 $this->msg( 'sp-contributions-userrights' )->escaped()
551 );
552 }
553
554 Hooks::run( 'ContributionsToolLinks', array( $id, $nt, &$tools ) );
555
556 $links = $this->getLanguage()->pipeList( $tools );
557
558 // Show a note if the user is blocked and display the last block log entry.
559 $block = Block::newFromTarget( $userObj, $userObj );
560 if ( !is_null( $block ) && $block->getType() != Block::TYPE_AUTO ) {
561 if ( $block->getType() == Block::TYPE_RANGE ) {
562 $nt = MWNamespace::getCanonicalName( NS_USER ) . ':' . $block->getTarget();
563 }
564
565 // LogEventsList::showLogExtract() wants the first parameter by ref
566 $out = $this->getOutput();
567 LogEventsList::showLogExtract(
568 $out,
569 'block',
570 $nt,
571 '',
572 array(
573 'lim' => 1,
574 'showIfEmpty' => false,
575 'msgKey' => array(
576 'sp-contributions-blocked-notice',
577 $userObj->getName() # Support GENDER in 'sp-contributions-blocked-notice'
578 ),
579 'offset' => '' # don't use $this->getRequest() parameter offset
580 )
581 );
582 }
583 }
584
585 return $this->msg( 'contribsub2' )->rawParams( $user, $links )->params( $userObj->getName() );
586 }
587
588 /**
589 * Generates the namespace selector form with hidden attributes.
590 * @param array $options The options to be included.
591 * @return string
592 */
593 function getForm( $options ) {
594 $options['title'] = $this->getPageTitle()->getPrefixedText();
595 if ( !isset( $options['target'] ) ) {
596 $options['target'] = '';
597 } else {
598 $options['target'] = str_replace( '_', ' ', $options['target'] );
599 }
600
601 if ( !isset( $options['namespace'] ) ) {
602 $options['namespace'] = '';
603 }
604
605 if ( !isset( $options['contribs'] ) ) {
606 $options['contribs'] = 'user';
607 }
608
609 if ( $options['contribs'] == 'newbie' ) {
610 $options['target'] = '';
611 }
612
613 $f = Xml::openElement( 'form', array( 'method' => 'get', 'action' => wfScript() ) );
614
615 foreach ( $options as $name => $value ) {
616 if ( in_array( $name, array( 'namespace', 'target', 'contribs' ) ) ) {
617 continue;
618 }
619 $f .= "\t" . Html::hidden( $name, $value ) . "\n";
620 }
621
622 $this->getOutput()->addModules( 'mediawiki.userSuggest' );
623
624 $f .= Xml::openElement( 'fieldset' );
625 $f .= Xml::element( 'legend', array(), $this->msg( 'sp-contributions-search' )->text() );
626 $f .= Xml::tags(
627 'label',
628 array( 'for' => 'target' ),
629 $this->msg( 'sp-contributions-username' )->parse()
630 ) . ' ';
631 $f .= Html::input(
632 'target',
633 $options['target'],
634 'text',
635 array(
636 'size' => '20',
637 'required' => '',
638 'class' => array(
639 'mw-autocomplete-user', // used by mediawiki.userSuggest
640 ),
641 ) + ( $options['target'] ? array() : array( 'autofocus' ) )
642 ) . ' ';
643 $f .= Html::namespaceSelector(
644 array(
645 'selected' => $options['namespace'],
646 'all' => '',
647 'label' => $this->msg( 'namespace' )->text()
648 ),
649 array(
650 'name' => 'namespace',
651 'id' => 'namespace',
652 'class' => 'namespaceselector',
653 )
654 ) . ' ';
655 $f .= Xml::submitButton( $this->msg( 'sp-contributions-submit' )->text() );
656 $f .= Xml::closeElement( 'fieldset' );
657 $f .= Xml::closeElement( 'form' );
658
659 return $f;
660 }
661
662 protected function getGroupName() {
663 return 'users';
664 }
665 }