Merge "Add support for PHP7 random_bytes in favor of mcrypt_create_iv"
[lhc/web/wiklou.git] / includes / specials / SpecialUndelete.php
1 <?php
2 /**
3 * Implements Special:Undelete
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 use Wikimedia\Rdbms\ResultWrapper;
25
26 /**
27 * Special page allowing users with the appropriate permissions to view
28 * and restore deleted content.
29 *
30 * @ingroup SpecialPage
31 */
32 class SpecialUndelete extends SpecialPage {
33 private $mAction;
34 private $mTarget;
35 private $mTimestamp;
36 private $mRestore;
37 private $mRevdel;
38 private $mInvert;
39 private $mFilename;
40 private $mTargetTimestamp;
41 private $mAllowed;
42 private $mCanView;
43 private $mComment;
44 private $mToken;
45
46 /** @var Title */
47 private $mTargetObj;
48
49 function __construct() {
50 parent::__construct( 'Undelete', 'deletedhistory' );
51 }
52
53 public function doesWrites() {
54 return true;
55 }
56
57 function loadRequest( $par ) {
58 $request = $this->getRequest();
59 $user = $this->getUser();
60
61 $this->mAction = $request->getVal( 'action' );
62 if ( $par !== null && $par !== '' ) {
63 $this->mTarget = $par;
64 } else {
65 $this->mTarget = $request->getVal( 'target' );
66 }
67
68 $this->mTargetObj = null;
69
70 if ( $this->mTarget !== null && $this->mTarget !== '' ) {
71 $this->mTargetObj = Title::newFromText( $this->mTarget );
72 }
73
74 $this->mSearchPrefix = $request->getText( 'prefix' );
75 $time = $request->getVal( 'timestamp' );
76 $this->mTimestamp = $time ? wfTimestamp( TS_MW, $time ) : '';
77 $this->mFilename = $request->getVal( 'file' );
78
79 $posted = $request->wasPosted() &&
80 $user->matchEditToken( $request->getVal( 'wpEditToken' ) );
81 $this->mRestore = $request->getCheck( 'restore' ) && $posted;
82 $this->mRevdel = $request->getCheck( 'revdel' ) && $posted;
83 $this->mInvert = $request->getCheck( 'invert' ) && $posted;
84 $this->mPreview = $request->getCheck( 'preview' ) && $posted;
85 $this->mDiff = $request->getCheck( 'diff' );
86 $this->mDiffOnly = $request->getBool( 'diffonly', $this->getUser()->getOption( 'diffonly' ) );
87 $this->mComment = $request->getText( 'wpComment' );
88 $this->mUnsuppress = $request->getVal( 'wpUnsuppress' ) && $user->isAllowed( 'suppressrevision' );
89 $this->mToken = $request->getVal( 'token' );
90
91 if ( $this->isAllowed( 'undelete' ) && !$user->isBlocked() ) {
92 $this->mAllowed = true; // user can restore
93 $this->mCanView = true; // user can view content
94 } elseif ( $this->isAllowed( 'deletedtext' ) ) {
95 $this->mAllowed = false; // user cannot restore
96 $this->mCanView = true; // user can view content
97 $this->mRestore = false;
98 } else { // user can only view the list of revisions
99 $this->mAllowed = false;
100 $this->mCanView = false;
101 $this->mTimestamp = '';
102 $this->mRestore = false;
103 }
104
105 if ( $this->mRestore || $this->mInvert ) {
106 $timestamps = [];
107 $this->mFileVersions = [];
108 foreach ( $request->getValues() as $key => $val ) {
109 $matches = [];
110 if ( preg_match( '/^ts(\d{14})$/', $key, $matches ) ) {
111 array_push( $timestamps, $matches[1] );
112 }
113
114 if ( preg_match( '/^fileid(\d+)$/', $key, $matches ) ) {
115 $this->mFileVersions[] = intval( $matches[1] );
116 }
117 }
118 rsort( $timestamps );
119 $this->mTargetTimestamp = $timestamps;
120 }
121 }
122
123 /**
124 * Checks whether a user is allowed the permission for the
125 * specific title if one is set.
126 *
127 * @param string $permission
128 * @param User $user
129 * @return bool
130 */
131 protected function isAllowed( $permission, User $user = null ) {
132 $user = $user ?: $this->getUser();
133 if ( $this->mTargetObj !== null ) {
134 return $this->mTargetObj->userCan( $permission, $user );
135 } else {
136 return $user->isAllowed( $permission );
137 }
138 }
139
140 function userCanExecute( User $user ) {
141 return $this->isAllowed( $this->mRestriction, $user );
142 }
143
144 function execute( $par ) {
145 $this->useTransactionalTimeLimit();
146
147 $user = $this->getUser();
148
149 $this->setHeaders();
150 $this->outputHeader();
151
152 $this->loadRequest( $par );
153 $this->checkPermissions(); // Needs to be after mTargetObj is set
154
155 $out = $this->getOutput();
156
157 if ( is_null( $this->mTargetObj ) ) {
158 $out->addWikiMsg( 'undelete-header' );
159
160 # Not all users can just browse every deleted page from the list
161 if ( $user->isAllowed( 'browsearchive' ) ) {
162 $this->showSearchForm();
163 }
164
165 return;
166 }
167
168 $this->addHelpLink( 'Help:Undelete' );
169 if ( $this->mAllowed ) {
170 $out->setPageTitle( $this->msg( 'undeletepage' ) );
171 } else {
172 $out->setPageTitle( $this->msg( 'viewdeletedpage' ) );
173 }
174
175 $this->getSkin()->setRelevantTitle( $this->mTargetObj );
176
177 if ( $this->mTimestamp !== '' ) {
178 $this->showRevision( $this->mTimestamp );
179 } elseif ( $this->mFilename !== null && $this->mTargetObj->inNamespace( NS_FILE ) ) {
180 $file = new ArchivedFile( $this->mTargetObj, '', $this->mFilename );
181 // Check if user is allowed to see this file
182 if ( !$file->exists() ) {
183 $out->addWikiMsg( 'filedelete-nofile', $this->mFilename );
184 } elseif ( !$file->userCan( File::DELETED_FILE, $user ) ) {
185 if ( $file->isDeleted( File::DELETED_RESTRICTED ) ) {
186 throw new PermissionsError( 'suppressrevision' );
187 } else {
188 throw new PermissionsError( 'deletedtext' );
189 }
190 } elseif ( !$user->matchEditToken( $this->mToken, $this->mFilename ) ) {
191 $this->showFileConfirmationForm( $this->mFilename );
192 } else {
193 $this->showFile( $this->mFilename );
194 }
195 } elseif ( $this->mAction === "submit" ) {
196 if ( $this->mRestore ) {
197 $this->undelete();
198 } elseif ( $this->mRevdel ) {
199 $this->redirectToRevDel();
200 }
201
202 } else {
203 $this->showHistory();
204 }
205 }
206
207 /**
208 * Convert submitted form data to format expected by RevisionDelete and
209 * redirect the request
210 */
211 private function redirectToRevDel() {
212 $archive = new PageArchive( $this->mTargetObj );
213
214 $revisions = [];
215
216 foreach ( $this->getRequest()->getValues() as $key => $val ) {
217 $matches = [];
218 if ( preg_match( "/^ts(\d{14})$/", $key, $matches ) ) {
219 $revisions[ $archive->getRevision( $matches[1] )->getId() ] = 1;
220 }
221 }
222 $query = [
223 "type" => "revision",
224 "ids" => $revisions,
225 "target" => $this->mTargetObj->getPrefixedText()
226 ];
227 $url = SpecialPage::getTitleFor( 'Revisiondelete' )->getFullURL( $query );
228 $this->getOutput()->redirect( $url );
229 }
230
231 function showSearchForm() {
232 $out = $this->getOutput();
233 $out->setPageTitle( $this->msg( 'undelete-search-title' ) );
234 $out->addHTML(
235 Xml::openElement( 'form', [ 'method' => 'get', 'action' => wfScript() ] ) .
236 Xml::fieldset( $this->msg( 'undelete-search-box' )->text() ) .
237 Html::hidden( 'title', $this->getPageTitle()->getPrefixedDBkey() ) .
238 Html::rawElement(
239 'label',
240 [ 'for' => 'prefix' ],
241 $this->msg( 'undelete-search-prefix' )->parse()
242 ) .
243 Xml::input(
244 'prefix',
245 20,
246 $this->mSearchPrefix,
247 [ 'id' => 'prefix', 'autofocus' => '' ]
248 ) . ' ' .
249 Xml::submitButton( $this->msg( 'undelete-search-submit' )->text() ) .
250 Xml::closeElement( 'fieldset' ) .
251 Xml::closeElement( 'form' )
252 );
253
254 # List undeletable articles
255 if ( $this->mSearchPrefix ) {
256 $result = PageArchive::listPagesByPrefix( $this->mSearchPrefix );
257 $this->showList( $result );
258 }
259 }
260
261 /**
262 * Generic list of deleted pages
263 *
264 * @param ResultWrapper $result
265 * @return bool
266 */
267 private function showList( $result ) {
268 $out = $this->getOutput();
269
270 if ( $result->numRows() == 0 ) {
271 $out->addWikiMsg( 'undelete-no-results' );
272
273 return false;
274 }
275
276 $out->addWikiMsg( 'undeletepagetext', $this->getLanguage()->formatNum( $result->numRows() ) );
277
278 $linkRenderer = $this->getLinkRenderer();
279 $undelete = $this->getPageTitle();
280 $out->addHTML( "<ul>\n" );
281 foreach ( $result as $row ) {
282 $title = Title::makeTitleSafe( $row->ar_namespace, $row->ar_title );
283 if ( $title !== null ) {
284 $item = $linkRenderer->makeKnownLink(
285 $undelete,
286 $title->getPrefixedText(),
287 [],
288 [ 'target' => $title->getPrefixedText() ]
289 );
290 } else {
291 // The title is no longer valid, show as text
292 $item = Html::element(
293 'span',
294 [ 'class' => 'mw-invalidtitle' ],
295 Linker::getInvalidTitleDescription(
296 $this->getContext(),
297 $row->ar_namespace,
298 $row->ar_title
299 )
300 );
301 }
302 $revs = $this->msg( 'undeleterevisions' )->numParams( $row->count )->parse();
303 $out->addHTML( "<li>{$item} ({$revs})</li>\n" );
304 }
305 $result->free();
306 $out->addHTML( "</ul>\n" );
307
308 return true;
309 }
310
311 private function showRevision( $timestamp ) {
312 if ( !preg_match( '/[0-9]{14}/', $timestamp ) ) {
313 return;
314 }
315
316 $archive = new PageArchive( $this->mTargetObj, $this->getConfig() );
317 if ( !Hooks::run( 'UndeleteForm::showRevision', [ &$archive, $this->mTargetObj ] ) ) {
318 return;
319 }
320 $rev = $archive->getRevision( $timestamp );
321
322 $out = $this->getOutput();
323 $user = $this->getUser();
324
325 if ( !$rev ) {
326 $out->addWikiMsg( 'undeleterevision-missing' );
327
328 return;
329 }
330
331 if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
332 if ( !$rev->userCan( Revision::DELETED_TEXT, $user ) ) {
333 $out->wrapWikiMsg(
334 "<div class='mw-warning plainlinks'>\n$1\n</div>\n",
335 $rev->isDeleted( Revision::DELETED_RESTRICTED ) ?
336 'rev-suppressed-text-permission' : 'rev-deleted-text-permission'
337 );
338
339 return;
340 }
341
342 $out->wrapWikiMsg(
343 "<div class='mw-warning plainlinks'>\n$1\n</div>\n",
344 $rev->isDeleted( Revision::DELETED_RESTRICTED ) ?
345 'rev-suppressed-text-view' : 'rev-deleted-text-view'
346 );
347 $out->addHTML( '<br />' );
348 // and we are allowed to see...
349 }
350
351 if ( $this->mDiff ) {
352 $previousRev = $archive->getPreviousRevision( $timestamp );
353 if ( $previousRev ) {
354 $this->showDiff( $previousRev, $rev );
355 if ( $this->mDiffOnly ) {
356 return;
357 }
358
359 $out->addHTML( '<hr />' );
360 } else {
361 $out->addWikiMsg( 'undelete-nodiff' );
362 }
363 }
364
365 $link = $this->getLinkRenderer()->makeKnownLink(
366 $this->getPageTitle( $this->mTargetObj->getPrefixedDBkey() ),
367 $this->mTargetObj->getPrefixedText()
368 );
369
370 $lang = $this->getLanguage();
371
372 // date and time are separate parameters to facilitate localisation.
373 // $time is kept for backward compat reasons.
374 $time = $lang->userTimeAndDate( $timestamp, $user );
375 $d = $lang->userDate( $timestamp, $user );
376 $t = $lang->userTime( $timestamp, $user );
377 $userLink = Linker::revUserTools( $rev );
378
379 $content = $rev->getContent( Revision::FOR_THIS_USER, $user );
380
381 $isText = ( $content instanceof TextContent );
382
383 if ( $this->mPreview || $isText ) {
384 $openDiv = '<div id="mw-undelete-revision" class="mw-warning">';
385 } else {
386 $openDiv = '<div id="mw-undelete-revision">';
387 }
388 $out->addHTML( $openDiv );
389
390 // Revision delete links
391 if ( !$this->mDiff ) {
392 $revdel = Linker::getRevDeleteLink( $user, $rev, $this->mTargetObj );
393 if ( $revdel ) {
394 $out->addHTML( "$revdel " );
395 }
396 }
397
398 $out->addHTML( $this->msg( 'undelete-revision' )->rawParams( $link )->params(
399 $time )->rawParams( $userLink )->params( $d, $t )->parse() . '</div>' );
400
401 if ( !Hooks::run( 'UndeleteShowRevision', [ $this->mTargetObj, $rev ] ) ) {
402 return;
403 }
404
405 if ( ( $this->mPreview || !$isText ) && $content ) {
406 // NOTE: non-text content has no source view, so always use rendered preview
407
408 // Hide [edit]s
409 $popts = $out->parserOptions();
410 $popts->setEditSection( false );
411
412 $pout = $content->getParserOutput( $this->mTargetObj, $rev->getId(), $popts, true );
413 $out->addParserOutput( $pout );
414 }
415
416 if ( $isText ) {
417 // source view for textual content
418 $sourceView = Xml::element(
419 'textarea',
420 [
421 'readonly' => 'readonly',
422 'cols' => 80,
423 'rows' => 25
424 ],
425 $content->getNativeData() . "\n"
426 );
427
428 $previewButton = Xml::element( 'input', [
429 'type' => 'submit',
430 'name' => 'preview',
431 'value' => $this->msg( 'showpreview' )->text()
432 ] );
433 } else {
434 $sourceView = '';
435 $previewButton = '';
436 }
437
438 $diffButton = Xml::element( 'input', [
439 'name' => 'diff',
440 'type' => 'submit',
441 'value' => $this->msg( 'showdiff' )->text() ] );
442
443 $out->addHTML(
444 $sourceView .
445 Xml::openElement( 'div', [
446 'style' => 'clear: both' ] ) .
447 Xml::openElement( 'form', [
448 'method' => 'post',
449 'action' => $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] ) ] ) .
450 Xml::element( 'input', [
451 'type' => 'hidden',
452 'name' => 'target',
453 'value' => $this->mTargetObj->getPrefixedDBkey() ] ) .
454 Xml::element( 'input', [
455 'type' => 'hidden',
456 'name' => 'timestamp',
457 'value' => $timestamp ] ) .
458 Xml::element( 'input', [
459 'type' => 'hidden',
460 'name' => 'wpEditToken',
461 'value' => $user->getEditToken() ] ) .
462 $previewButton .
463 $diffButton .
464 Xml::closeElement( 'form' ) .
465 Xml::closeElement( 'div' )
466 );
467 }
468
469 /**
470 * Build a diff display between this and the previous either deleted
471 * or non-deleted edit.
472 *
473 * @param Revision $previousRev
474 * @param Revision $currentRev
475 * @return string HTML
476 */
477 function showDiff( $previousRev, $currentRev ) {
478 $diffContext = clone $this->getContext();
479 $diffContext->setTitle( $currentRev->getTitle() );
480 $diffContext->setWikiPage( WikiPage::factory( $currentRev->getTitle() ) );
481
482 $diffEngine = $currentRev->getContentHandler()->createDifferenceEngine( $diffContext );
483 $diffEngine->showDiffStyle();
484
485 $formattedDiff = $diffEngine->generateContentDiffBody(
486 $previousRev->getContent( Revision::FOR_THIS_USER, $this->getUser() ),
487 $currentRev->getContent( Revision::FOR_THIS_USER, $this->getUser() )
488 );
489
490 $formattedDiff = $diffEngine->addHeader(
491 $formattedDiff,
492 $this->diffHeader( $previousRev, 'o' ),
493 $this->diffHeader( $currentRev, 'n' )
494 );
495
496 $this->getOutput()->addHTML( "<div>$formattedDiff</div>\n" );
497 }
498
499 /**
500 * @param Revision $rev
501 * @param string $prefix
502 * @return string
503 */
504 private function diffHeader( $rev, $prefix ) {
505 $isDeleted = !( $rev->getId() && $rev->getTitle() );
506 if ( $isDeleted ) {
507 /// @todo FIXME: $rev->getTitle() is null for deleted revs...?
508 $targetPage = $this->getPageTitle();
509 $targetQuery = [
510 'target' => $this->mTargetObj->getPrefixedText(),
511 'timestamp' => wfTimestamp( TS_MW, $rev->getTimestamp() )
512 ];
513 } else {
514 /// @todo FIXME: getId() may return non-zero for deleted revs...
515 $targetPage = $rev->getTitle();
516 $targetQuery = [ 'oldid' => $rev->getId() ];
517 }
518
519 // Add show/hide deletion links if available
520 $user = $this->getUser();
521 $lang = $this->getLanguage();
522 $rdel = Linker::getRevDeleteLink( $user, $rev, $this->mTargetObj );
523
524 if ( $rdel ) {
525 $rdel = " $rdel";
526 }
527
528 $minor = $rev->isMinor() ? ChangesList::flag( 'minor' ) : '';
529
530 $tags = wfGetDB( DB_REPLICA )->selectField(
531 'tag_summary',
532 'ts_tags',
533 [ 'ts_rev_id' => $rev->getId() ],
534 __METHOD__
535 );
536 $tagSummary = ChangeTags::formatSummaryRow( $tags, 'deleteddiff', $this->getContext() );
537
538 // FIXME This is reimplementing DifferenceEngine#getRevisionHeader
539 // and partially #showDiffPage, but worse
540 return '<div id="mw-diff-' . $prefix . 'title1"><strong>' .
541 $this->getLinkRenderer()->makeLink(
542 $targetPage,
543 $this->msg(
544 'revisionasof',
545 $lang->userTimeAndDate( $rev->getTimestamp(), $user ),
546 $lang->userDate( $rev->getTimestamp(), $user ),
547 $lang->userTime( $rev->getTimestamp(), $user )
548 )->text(),
549 [],
550 $targetQuery
551 ) .
552 '</strong></div>' .
553 '<div id="mw-diff-' . $prefix . 'title2">' .
554 Linker::revUserTools( $rev ) . '<br />' .
555 '</div>' .
556 '<div id="mw-diff-' . $prefix . 'title3">' .
557 $minor . Linker::revComment( $rev ) . $rdel . '<br />' .
558 '</div>' .
559 '<div id="mw-diff-' . $prefix . 'title5">' .
560 $tagSummary[0] . '<br />' .
561 '</div>';
562 }
563
564 /**
565 * Show a form confirming whether a tokenless user really wants to see a file
566 * @param string $key
567 */
568 private function showFileConfirmationForm( $key ) {
569 $out = $this->getOutput();
570 $lang = $this->getLanguage();
571 $user = $this->getUser();
572 $file = new ArchivedFile( $this->mTargetObj, '', $this->mFilename );
573 $out->addWikiMsg( 'undelete-show-file-confirm',
574 $this->mTargetObj->getText(),
575 $lang->userDate( $file->getTimestamp(), $user ),
576 $lang->userTime( $file->getTimestamp(), $user ) );
577 $out->addHTML(
578 Xml::openElement( 'form', [
579 'method' => 'POST',
580 'action' => $this->getPageTitle()->getLocalURL( [
581 'target' => $this->mTarget,
582 'file' => $key,
583 'token' => $user->getEditToken( $key ),
584 ] ),
585 ]
586 ) .
587 Xml::submitButton( $this->msg( 'undelete-show-file-submit' )->text() ) .
588 '</form>'
589 );
590 }
591
592 /**
593 * Show a deleted file version requested by the visitor.
594 * @param string $key
595 */
596 private function showFile( $key ) {
597 $this->getOutput()->disable();
598
599 # We mustn't allow the output to be CDN cached, otherwise
600 # if an admin previews a deleted image, and it's cached, then
601 # a user without appropriate permissions can toddle off and
602 # nab the image, and CDN will serve it
603 $response = $this->getRequest()->response();
604 $response->header( 'Expires: ' . gmdate( 'D, d M Y H:i:s', 0 ) . ' GMT' );
605 $response->header( 'Cache-Control: no-cache, no-store, max-age=0, must-revalidate' );
606 $response->header( 'Pragma: no-cache' );
607
608 $repo = RepoGroup::singleton()->getLocalRepo();
609 $path = $repo->getZonePath( 'deleted' ) . '/' . $repo->getDeletedHashPath( $key ) . $key;
610 $repo->streamFile( $path );
611 }
612
613 protected function showHistory() {
614 $this->checkReadOnly();
615
616 $out = $this->getOutput();
617 if ( $this->mAllowed ) {
618 $out->addModules( 'mediawiki.special.undelete' );
619 }
620 $out->wrapWikiMsg(
621 "<div class='mw-undelete-pagetitle'>\n$1\n</div>\n",
622 [ 'undeletepagetitle', wfEscapeWikiText( $this->mTargetObj->getPrefixedText() ) ]
623 );
624
625 $archive = new PageArchive( $this->mTargetObj, $this->getConfig() );
626 Hooks::run( 'UndeleteForm::showHistory', [ &$archive, $this->mTargetObj ] );
627 /*
628 $text = $archive->getLastRevisionText();
629 if( is_null( $text ) ) {
630 $out->addWikiMsg( 'nohistory' );
631 return;
632 }
633 */
634 $out->addHTML( '<div class="mw-undelete-history">' );
635 if ( $this->mAllowed ) {
636 $out->addWikiMsg( 'undeletehistory' );
637 $out->addWikiMsg( 'undeleterevdel' );
638 } else {
639 $out->addWikiMsg( 'undeletehistorynoadmin' );
640 }
641 $out->addHTML( '</div>' );
642
643 # List all stored revisions
644 $revisions = $archive->listRevisions();
645 $files = $archive->listFiles();
646
647 $haveRevisions = $revisions && $revisions->numRows() > 0;
648 $haveFiles = $files && $files->numRows() > 0;
649
650 # Batch existence check on user and talk pages
651 if ( $haveRevisions ) {
652 $batch = new LinkBatch();
653 foreach ( $revisions as $row ) {
654 $batch->addObj( Title::makeTitleSafe( NS_USER, $row->ar_user_text ) );
655 $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->ar_user_text ) );
656 }
657 $batch->execute();
658 $revisions->seek( 0 );
659 }
660 if ( $haveFiles ) {
661 $batch = new LinkBatch();
662 foreach ( $files as $row ) {
663 $batch->addObj( Title::makeTitleSafe( NS_USER, $row->fa_user_text ) );
664 $batch->addObj( Title::makeTitleSafe( NS_USER_TALK, $row->fa_user_text ) );
665 }
666 $batch->execute();
667 $files->seek( 0 );
668 }
669
670 if ( $this->mAllowed ) {
671 $action = $this->getPageTitle()->getLocalURL( [ 'action' => 'submit' ] );
672 # Start the form here
673 $top = Xml::openElement(
674 'form',
675 [ 'method' => 'post', 'action' => $action, 'id' => 'undelete' ]
676 );
677 $out->addHTML( $top );
678 }
679
680 # Show relevant lines from the deletion log:
681 $deleteLogPage = new LogPage( 'delete' );
682 $out->addHTML( Xml::element( 'h2', null, $deleteLogPage->getName()->text() ) . "\n" );
683 LogEventsList::showLogExtract( $out, 'delete', $this->mTargetObj );
684 # Show relevant lines from the suppression log:
685 $suppressLogPage = new LogPage( 'suppress' );
686 if ( $this->getUser()->isAllowed( 'suppressionlog' ) ) {
687 $out->addHTML( Xml::element( 'h2', null, $suppressLogPage->getName()->text() ) . "\n" );
688 LogEventsList::showLogExtract( $out, 'suppress', $this->mTargetObj );
689 }
690
691 if ( $this->mAllowed && ( $haveRevisions || $haveFiles ) ) {
692 # Format the user-visible controls (comment field, submission button)
693 # in a nice little table
694 if ( $this->getUser()->isAllowed( 'suppressrevision' ) ) {
695 $unsuppressBox =
696 "<tr>
697 <td>&#160;</td>
698 <td class='mw-input'>" .
699 Xml::checkLabel( $this->msg( 'revdelete-unsuppress' )->text(),
700 'wpUnsuppress', 'mw-undelete-unsuppress', $this->mUnsuppress ) .
701 "</td>
702 </tr>";
703 } else {
704 $unsuppressBox = '';
705 }
706
707 $table = Xml::fieldset( $this->msg( 'undelete-fieldset-title' )->text() ) .
708 Xml::openElement( 'table', [ 'id' => 'mw-undelete-table' ] ) .
709 "<tr>
710 <td colspan='2' class='mw-undelete-extrahelp'>" .
711 $this->msg( 'undeleteextrahelp' )->parseAsBlock() .
712 "</td>
713 </tr>
714 <tr>
715 <td class='mw-label'>" .
716 Xml::label( $this->msg( 'undeletecomment' )->text(), 'wpComment' ) .
717 "</td>
718 <td class='mw-input'>" .
719 Xml::input(
720 'wpComment',
721 50,
722 $this->mComment,
723 [ 'id' => 'wpComment', 'autofocus' => '' ]
724 ) .
725 "</td>
726 </tr>
727 <tr>
728 <td>&#160;</td>
729 <td class='mw-submit'>" .
730 Xml::submitButton(
731 $this->msg( 'undeletebtn' )->text(),
732 [ 'name' => 'restore', 'id' => 'mw-undelete-submit' ]
733 ) . ' ' .
734 Xml::submitButton(
735 $this->msg( 'undeleteinvert' )->text(),
736 [ 'name' => 'invert', 'id' => 'mw-undelete-invert' ]
737 ) .
738 "</td>
739 </tr>" .
740 $unsuppressBox .
741 Xml::closeElement( 'table' ) .
742 Xml::closeElement( 'fieldset' );
743
744 $out->addHTML( $table );
745 }
746
747 $out->addHTML( Xml::element( 'h2', null, $this->msg( 'history' )->text() ) . "\n" );
748
749 if ( $haveRevisions ) {
750 # Show the page's stored (deleted) history
751
752 if ( $this->getUser()->isAllowed( 'deleterevision' ) ) {
753 $out->addHTML( Html::element(
754 'button',
755 [
756 'name' => 'revdel',
757 'type' => 'submit',
758 'class' => 'deleterevision-log-submit mw-log-deleterevision-button'
759 ],
760 $this->msg( 'showhideselectedversions' )->text()
761 ) . "\n" );
762 }
763
764 $out->addHTML( '<ul>' );
765 $remaining = $revisions->numRows();
766 $earliestLiveTime = $this->mTargetObj->getEarliestRevTime();
767
768 foreach ( $revisions as $row ) {
769 $remaining--;
770 $out->addHTML( $this->formatRevisionRow( $row, $earliestLiveTime, $remaining ) );
771 }
772 $revisions->free();
773 $out->addHTML( '</ul>' );
774 } else {
775 $out->addWikiMsg( 'nohistory' );
776 }
777
778 if ( $haveFiles ) {
779 $out->addHTML( Xml::element( 'h2', null, $this->msg( 'filehist' )->text() ) . "\n" );
780 $out->addHTML( '<ul>' );
781 foreach ( $files as $row ) {
782 $out->addHTML( $this->formatFileRow( $row ) );
783 }
784 $files->free();
785 $out->addHTML( '</ul>' );
786 }
787
788 if ( $this->mAllowed ) {
789 # Slip in the hidden controls here
790 $misc = Html::hidden( 'target', $this->mTarget );
791 $misc .= Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() );
792 $misc .= Xml::closeElement( 'form' );
793 $out->addHTML( $misc );
794 }
795
796 return true;
797 }
798
799 protected function formatRevisionRow( $row, $earliestLiveTime, $remaining ) {
800 $rev = Revision::newFromArchiveRow( $row,
801 [
802 'title' => $this->mTargetObj
803 ] );
804
805 $revTextSize = '';
806 $ts = wfTimestamp( TS_MW, $row->ar_timestamp );
807 // Build checkboxen...
808 if ( $this->mAllowed ) {
809 if ( $this->mInvert ) {
810 if ( in_array( $ts, $this->mTargetTimestamp ) ) {
811 $checkBox = Xml::check( "ts$ts" );
812 } else {
813 $checkBox = Xml::check( "ts$ts", true );
814 }
815 } else {
816 $checkBox = Xml::check( "ts$ts" );
817 }
818 } else {
819 $checkBox = '';
820 }
821
822 // Build page & diff links...
823 $user = $this->getUser();
824 if ( $this->mCanView ) {
825 $titleObj = $this->getPageTitle();
826 # Last link
827 if ( !$rev->userCan( Revision::DELETED_TEXT, $this->getUser() ) ) {
828 $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
829 $last = $this->msg( 'diff' )->escaped();
830 } elseif ( $remaining > 0 || ( $earliestLiveTime && $ts > $earliestLiveTime ) ) {
831 $pageLink = $this->getPageLink( $rev, $titleObj, $ts );
832 $last = $this->getLinkRenderer()->makeKnownLink(
833 $titleObj,
834 $this->msg( 'diff' )->text(),
835 [],
836 [
837 'target' => $this->mTargetObj->getPrefixedText(),
838 'timestamp' => $ts,
839 'diff' => 'prev'
840 ]
841 );
842 } else {
843 $pageLink = $this->getPageLink( $rev, $titleObj, $ts );
844 $last = $this->msg( 'diff' )->escaped();
845 }
846 } else {
847 $pageLink = htmlspecialchars( $this->getLanguage()->userTimeAndDate( $ts, $user ) );
848 $last = $this->msg( 'diff' )->escaped();
849 }
850
851 // User links
852 $userLink = Linker::revUserTools( $rev );
853
854 // Minor edit
855 $minor = $rev->isMinor() ? ChangesList::flag( 'minor' ) : '';
856
857 // Revision text size
858 $size = $row->ar_len;
859 if ( !is_null( $size ) ) {
860 $revTextSize = Linker::formatRevisionSize( $size );
861 }
862
863 // Edit summary
864 $comment = Linker::revComment( $rev );
865
866 // Tags
867 $attribs = [];
868 list( $tagSummary, $classes ) = ChangeTags::formatSummaryRow(
869 $row->ts_tags,
870 'deletedhistory',
871 $this->getContext()
872 );
873 if ( $classes ) {
874 $attribs['class'] = implode( ' ', $classes );
875 }
876
877 $revisionRow = $this->msg( 'undelete-revision-row2' )
878 ->rawParams(
879 $checkBox,
880 $last,
881 $pageLink,
882 $userLink,
883 $minor,
884 $revTextSize,
885 $comment,
886 $tagSummary
887 )
888 ->escaped();
889
890 return Xml::tags( 'li', $attribs, $revisionRow ) . "\n";
891 }
892
893 private function formatFileRow( $row ) {
894 $file = ArchivedFile::newFromRow( $row );
895 $ts = wfTimestamp( TS_MW, $row->fa_timestamp );
896 $user = $this->getUser();
897
898 $checkBox = '';
899 if ( $this->mCanView && $row->fa_storage_key ) {
900 if ( $this->mAllowed ) {
901 $checkBox = Xml::check( 'fileid' . $row->fa_id );
902 }
903 $key = urlencode( $row->fa_storage_key );
904 $pageLink = $this->getFileLink( $file, $this->getPageTitle(), $ts, $key );
905 } else {
906 $pageLink = $this->getLanguage()->userTimeAndDate( $ts, $user );
907 }
908 $userLink = $this->getFileUser( $file );
909 $data = $this->msg( 'widthheight' )->numParams( $row->fa_width, $row->fa_height )->text();
910 $bytes = $this->msg( 'parentheses' )
911 ->rawParams( $this->msg( 'nbytes' )->numParams( $row->fa_size )->text() )
912 ->plain();
913 $data = htmlspecialchars( $data . ' ' . $bytes );
914 $comment = $this->getFileComment( $file );
915
916 // Add show/hide deletion links if available
917 $canHide = $this->isAllowed( 'deleterevision' );
918 if ( $canHide || ( $file->getVisibility() && $this->isAllowed( 'deletedhistory' ) ) ) {
919 if ( !$file->userCan( File::DELETED_RESTRICTED, $user ) ) {
920 // Revision was hidden from sysops
921 $revdlink = Linker::revDeleteLinkDisabled( $canHide );
922 } else {
923 $query = [
924 'type' => 'filearchive',
925 'target' => $this->mTargetObj->getPrefixedDBkey(),
926 'ids' => $row->fa_id
927 ];
928 $revdlink = Linker::revDeleteLink( $query,
929 $file->isDeleted( File::DELETED_RESTRICTED ), $canHide );
930 }
931 } else {
932 $revdlink = '';
933 }
934
935 return "<li>$checkBox $revdlink $pageLink . . $userLink $data $comment</li>\n";
936 }
937
938 /**
939 * Fetch revision text link if it's available to all users
940 *
941 * @param Revision $rev
942 * @param Title $titleObj
943 * @param string $ts Timestamp
944 * @return string
945 */
946 function getPageLink( $rev, $titleObj, $ts ) {
947 $user = $this->getUser();
948 $time = $this->getLanguage()->userTimeAndDate( $ts, $user );
949
950 if ( !$rev->userCan( Revision::DELETED_TEXT, $user ) ) {
951 return '<span class="history-deleted">' . $time . '</span>';
952 }
953
954 $link = $this->getLinkRenderer()->makeKnownLink(
955 $titleObj,
956 $time,
957 [],
958 [
959 'target' => $this->mTargetObj->getPrefixedText(),
960 'timestamp' => $ts
961 ]
962 );
963
964 if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
965 $link = '<span class="history-deleted">' . $link . '</span>';
966 }
967
968 return $link;
969 }
970
971 /**
972 * Fetch image view link if it's available to all users
973 *
974 * @param File|ArchivedFile $file
975 * @param Title $titleObj
976 * @param string $ts A timestamp
977 * @param string $key A storage key
978 *
979 * @return string HTML fragment
980 */
981 function getFileLink( $file, $titleObj, $ts, $key ) {
982 $user = $this->getUser();
983 $time = $this->getLanguage()->userTimeAndDate( $ts, $user );
984
985 if ( !$file->userCan( File::DELETED_FILE, $user ) ) {
986 return '<span class="history-deleted">' . $time . '</span>';
987 }
988
989 $link = $this->getLinkRenderer()->makeKnownLink(
990 $titleObj,
991 $time,
992 [],
993 [
994 'target' => $this->mTargetObj->getPrefixedText(),
995 'file' => $key,
996 'token' => $user->getEditToken( $key )
997 ]
998 );
999
1000 if ( $file->isDeleted( File::DELETED_FILE ) ) {
1001 $link = '<span class="history-deleted">' . $link . '</span>';
1002 }
1003
1004 return $link;
1005 }
1006
1007 /**
1008 * Fetch file's user id if it's available to this user
1009 *
1010 * @param File|ArchivedFile $file
1011 * @return string HTML fragment
1012 */
1013 function getFileUser( $file ) {
1014 if ( !$file->userCan( File::DELETED_USER, $this->getUser() ) ) {
1015 return '<span class="history-deleted">' .
1016 $this->msg( 'rev-deleted-user' )->escaped() .
1017 '</span>';
1018 }
1019
1020 $link = Linker::userLink( $file->getRawUser(), $file->getRawUserText() ) .
1021 Linker::userToolLinks( $file->getRawUser(), $file->getRawUserText() );
1022
1023 if ( $file->isDeleted( File::DELETED_USER ) ) {
1024 $link = '<span class="history-deleted">' . $link . '</span>';
1025 }
1026
1027 return $link;
1028 }
1029
1030 /**
1031 * Fetch file upload comment if it's available to this user
1032 *
1033 * @param File|ArchivedFile $file
1034 * @return string HTML fragment
1035 */
1036 function getFileComment( $file ) {
1037 if ( !$file->userCan( File::DELETED_COMMENT, $this->getUser() ) ) {
1038 return '<span class="history-deleted"><span class="comment">' .
1039 $this->msg( 'rev-deleted-comment' )->escaped() . '</span></span>';
1040 }
1041
1042 $link = Linker::commentBlock( $file->getRawDescription() );
1043
1044 if ( $file->isDeleted( File::DELETED_COMMENT ) ) {
1045 $link = '<span class="history-deleted">' . $link . '</span>';
1046 }
1047
1048 return $link;
1049 }
1050
1051 function undelete() {
1052 if ( $this->getConfig()->get( 'UploadMaintenance' )
1053 && $this->mTargetObj->getNamespace() == NS_FILE
1054 ) {
1055 throw new ErrorPageError( 'undelete-error', 'filedelete-maintenance' );
1056 }
1057
1058 $this->checkReadOnly();
1059
1060 $out = $this->getOutput();
1061 $archive = new PageArchive( $this->mTargetObj, $this->getConfig() );
1062 Hooks::run( 'UndeleteForm::undelete', [ &$archive, $this->mTargetObj ] );
1063 $ok = $archive->undelete(
1064 $this->mTargetTimestamp,
1065 $this->mComment,
1066 $this->mFileVersions,
1067 $this->mUnsuppress,
1068 $this->getUser()
1069 );
1070
1071 if ( is_array( $ok ) ) {
1072 if ( $ok[1] ) { // Undeleted file count
1073 Hooks::run( 'FileUndeleteComplete', [
1074 $this->mTargetObj, $this->mFileVersions,
1075 $this->getUser(), $this->mComment ] );
1076 }
1077
1078 $link = $this->getLinkRenderer()->makeKnownLink( $this->mTargetObj );
1079 $out->addHTML( $this->msg( 'undeletedpage' )->rawParams( $link )->parse() );
1080 } else {
1081 $out->setPageTitle( $this->msg( 'undelete-error' ) );
1082 }
1083
1084 // Show revision undeletion warnings and errors
1085 $status = $archive->getRevisionStatus();
1086 if ( $status && !$status->isGood() ) {
1087 $out->wrapWikiMsg(
1088 "<div class=\"error\" id=\"mw-error-cannotundelete\">\n$1\n</div>",
1089 'cannotundelete'
1090 );
1091 }
1092
1093 // Show file undeletion warnings and errors
1094 $status = $archive->getFileStatus();
1095 if ( $status && !$status->isGood() ) {
1096 $out->addWikiText( '<div class="error">' .
1097 $status->getWikiText(
1098 'undelete-error-short',
1099 'undelete-error-long'
1100 ) . '</div>'
1101 );
1102 }
1103 }
1104
1105 /**
1106 * Return an array of subpages beginning with $search that this special page will accept.
1107 *
1108 * @param string $search Prefix to search for
1109 * @param int $limit Maximum number of results to return (usually 10)
1110 * @param int $offset Number of results to skip (usually 0)
1111 * @return string[] Matching subpages
1112 */
1113 public function prefixSearchSubpages( $search, $limit, $offset ) {
1114 return $this->prefixSearchString( $search, $limit, $offset );
1115 }
1116
1117 protected function getGroupName() {
1118 return 'pagetools';
1119 }
1120 }