3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
21 use MediaWiki\MediaWikiServices
;
22 use Wikimedia\Rdbms\ResultWrapper
;
23 use Wikimedia\Rdbms\IDatabase
;
26 * Used to show archived pages and eventually restore them.
33 protected $fileStatus;
36 protected $revisionStatus;
41 public function __construct( $title, Config
$config = null ) {
42 if ( is_null( $title ) ) {
43 throw new MWException( __METHOD__
. ' given a null title.' );
45 $this->title
= $title;
46 if ( $config === null ) {
47 wfDebug( __METHOD__
. ' did not have a Config object passed to it' );
48 $config = MediaWikiServices
::getInstance()->getMainConfig();
50 $this->config
= $config;
53 public function doesWrites() {
58 * List all deleted pages recorded in the archive table. Returns result
59 * wrapper with (ar_namespace, ar_title, count) fields, ordered by page
62 * @return ResultWrapper
64 public static function listAllPages() {
65 $dbr = wfGetDB( DB_REPLICA
);
67 return self
::listPages( $dbr, '' );
71 * List deleted pages recorded in the archive matching the
72 * given term, using search engine archive.
73 * Returns result wrapper with (ar_namespace, ar_title, count) fields.
75 * @param string $term Search term
76 * @return ResultWrapper
78 public static function listPagesBySearch( $term ) {
79 $title = Title
::newFromText( $term );
81 $ns = $title->getNamespace();
82 $termMain = $title->getText();
83 $termDb = $title->getDBkey();
85 // Prolly won't work too good
86 // @todo handle bare namespace names cleanly?
88 $termMain = $termDb = $term;
91 // Try search engine first
92 $engine = MediaWikiServices
::getInstance()->newSearchEngine();
93 $engine->setLimitOffset( 100 );
94 $engine->setNamespaces( [ $ns ] );
95 $results = $engine->searchArchiveTitle( $termMain );
96 if ( !$results->isOK() ) {
99 $results = $results->getValue();
103 // Fall back to regular prefix search
104 return self
::listPagesByPrefix( $term );
107 $dbr = wfGetDB( DB_REPLICA
);
108 $condTitles = array_unique( array_map( function ( Title
$t ) {
109 return $t->getDBkey();
112 'ar_namespace' => $ns,
113 $dbr->makeList( [ 'ar_title' => $condTitles ], LIST_OR
) . " OR ar_title " .
114 $dbr->buildLike( $termDb, $dbr->anyString() )
117 return self
::listPages( $dbr, $conds );
121 * List deleted pages recorded in the archive table matching the
122 * given title prefix.
123 * Returns result wrapper with (ar_namespace, ar_title, count) fields.
125 * @param string $prefix Title prefix
126 * @return ResultWrapper
128 public static function listPagesByPrefix( $prefix ) {
129 $dbr = wfGetDB( DB_REPLICA
);
131 $title = Title
::newFromText( $prefix );
133 $ns = $title->getNamespace();
134 $prefix = $title->getDBkey();
136 // Prolly won't work too good
137 // @todo handle bare namespace names cleanly?
142 'ar_namespace' => $ns,
143 'ar_title' . $dbr->buildLike( $prefix, $dbr->anyString() ),
146 return self
::listPages( $dbr, $conds );
150 * @param IDatabase $dbr
151 * @param string|array $condition
152 * @return bool|ResultWrapper
154 protected static function listPages( $dbr, $condition ) {
160 'count' => 'COUNT(*)'
165 'GROUP BY' => [ 'ar_namespace', 'ar_title' ],
166 'ORDER BY' => [ 'ar_namespace', 'ar_title' ],
173 * List the revisions of the given page. Returns result wrapper with
174 * various archive table fields.
176 * @return ResultWrapper
178 public function listRevisions() {
179 $dbr = wfGetDB( DB_REPLICA
);
180 $commentQuery = CommentStore
::newKey( 'ar_comment' )->getJoin();
182 $tables = [ 'archive' ] +
$commentQuery['tables'];
185 'ar_minor_edit', 'ar_timestamp', 'ar_user', 'ar_user_text',
186 'ar_len', 'ar_deleted', 'ar_rev_id', 'ar_sha1',
188 ] +
$commentQuery['fields'];
190 if ( $this->config
->get( 'ContentHandlerUseDB' ) ) {
191 $fields[] = 'ar_content_format';
192 $fields[] = 'ar_content_model';
195 $conds = [ 'ar_namespace' => $this->title
->getNamespace(),
196 'ar_title' => $this->title
->getDBkey() ];
198 $options = [ 'ORDER BY' => 'ar_timestamp DESC' ];
200 $join_conds = [] +
$commentQuery['joins'];
202 ChangeTags
::modifyDisplayQuery(
211 return $dbr->select( $tables,
221 * List the deleted file revisions for this page, if it's a file page.
222 * Returns a result wrapper with various filearchive fields, or null
223 * if not a file page.
225 * @return ResultWrapper
226 * @todo Does this belong in Image for fuller encapsulation?
228 public function listFiles() {
229 if ( $this->title
->getNamespace() != NS_FILE
) {
233 $dbr = wfGetDB( DB_REPLICA
);
236 ArchivedFile
::selectFields(),
237 [ 'fa_name' => $this->title
->getDBkey() ],
239 [ 'ORDER BY' => 'fa_timestamp DESC' ]
244 * Return a Revision object containing data for the deleted revision.
245 * Note that the result *may* or *may not* have a null page ID.
247 * @param string $timestamp
248 * @return Revision|null
250 public function getRevision( $timestamp ) {
251 $dbr = wfGetDB( DB_REPLICA
);
252 $commentQuery = CommentStore
::newKey( 'ar_comment' )->getJoin();
254 $tables = [ 'archive' ] +
$commentQuery['tables'];
268 ] +
$commentQuery['fields'];
270 if ( $this->config
->get( 'ContentHandlerUseDB' ) ) {
271 $fields[] = 'ar_content_format';
272 $fields[] = 'ar_content_model';
275 $join_conds = [] +
$commentQuery['joins'];
277 $row = $dbr->selectRow(
281 'ar_namespace' => $this->title
->getNamespace(),
282 'ar_title' => $this->title
->getDBkey(),
283 'ar_timestamp' => $dbr->timestamp( $timestamp )
291 return Revision
::newFromArchiveRow( $row, [ 'title' => $this->title
] );
298 * Return the most-previous revision, either live or deleted, against
299 * the deleted revision given by timestamp.
301 * May produce unexpected results in case of history merges or other
302 * unusual time issues.
304 * @param string $timestamp
305 * @return Revision|null Null when there is no previous revision
307 public function getPreviousRevision( $timestamp ) {
308 $dbr = wfGetDB( DB_REPLICA
);
310 // Check the previous deleted revision...
311 $row = $dbr->selectRow( 'archive',
313 [ 'ar_namespace' => $this->title
->getNamespace(),
314 'ar_title' => $this->title
->getDBkey(),
316 $dbr->addQuotes( $dbr->timestamp( $timestamp ) ) ],
319 'ORDER BY' => 'ar_timestamp DESC',
321 $prevDeleted = $row ?
wfTimestamp( TS_MW
, $row->ar_timestamp
) : false;
323 $row = $dbr->selectRow( [ 'page', 'revision' ],
324 [ 'rev_id', 'rev_timestamp' ],
326 'page_namespace' => $this->title
->getNamespace(),
327 'page_title' => $this->title
->getDBkey(),
328 'page_id = rev_page',
330 $dbr->addQuotes( $dbr->timestamp( $timestamp ) ) ],
333 'ORDER BY' => 'rev_timestamp DESC',
335 $prevLive = $row ?
wfTimestamp( TS_MW
, $row->rev_timestamp
) : false;
336 $prevLiveId = $row ?
intval( $row->rev_id
) : null;
338 if ( $prevLive && $prevLive > $prevDeleted ) {
339 // Most prior revision was live
340 return Revision
::newFromId( $prevLiveId );
341 } elseif ( $prevDeleted ) {
342 // Most prior revision was deleted
343 return $this->getRevision( $prevDeleted );
346 // No prior revision on this page.
351 * Get the text from an archive row containing ar_text, ar_flags and ar_text_id
353 * @param object $row Database row
356 public function getTextFromRow( $row ) {
357 if ( is_null( $row->ar_text_id
) ) {
358 // An old row from MediaWiki 1.4 or previous.
359 // Text is embedded in this row in classic compression format.
360 return Revision
::getRevisionText( $row, 'ar_' );
363 // New-style: keyed to the text storage backend.
364 $dbr = wfGetDB( DB_REPLICA
);
365 $text = $dbr->selectRow( 'text',
366 [ 'old_text', 'old_flags' ],
367 [ 'old_id' => $row->ar_text_id
],
370 return Revision
::getRevisionText( $text );
374 * Fetch (and decompress if necessary) the stored text of the most
375 * recently edited deleted revision of the page.
377 * If there are no archived revisions for the page, returns NULL.
379 * @return string|null
381 public function getLastRevisionText() {
382 $dbr = wfGetDB( DB_REPLICA
);
383 $row = $dbr->selectRow( 'archive',
384 [ 'ar_text', 'ar_flags', 'ar_text_id' ],
385 [ 'ar_namespace' => $this->title
->getNamespace(),
386 'ar_title' => $this->title
->getDBkey() ],
388 [ 'ORDER BY' => 'ar_timestamp DESC' ] );
391 return $this->getTextFromRow( $row );
398 * Quick check if any archived revisions are present for the page.
402 public function isDeleted() {
403 $dbr = wfGetDB( DB_REPLICA
);
404 $n = $dbr->selectField( 'archive', 'COUNT(ar_title)',
405 [ 'ar_namespace' => $this->title
->getNamespace(),
406 'ar_title' => $this->title
->getDBkey() ],
414 * Restore the given (or all) text and file revisions for the page.
415 * Once restored, the items will be removed from the archive tables.
416 * The deletion log will be updated with an undeletion notice.
418 * This also sets Status objects, $this->fileStatus and $this->revisionStatus
419 * (depending what operations are attempted).
421 * @param array $timestamps Pass an empty array to restore all revisions,
422 * otherwise list the ones to undelete.
423 * @param string $comment
424 * @param array $fileVersions
425 * @param bool $unsuppress
426 * @param User $user User performing the action, or null to use $wgUser
427 * @param string|string[] $tags Change tags to add to log entry
428 * ($user should be able to add the specified tags before this is called)
429 * @return array|bool array(number of file revisions restored, number of image revisions
430 * restored, log message) on success, false on failure.
432 public function undelete( $timestamps, $comment = '', $fileVersions = [],
433 $unsuppress = false, User
$user = null, $tags = null
435 // If both the set of text revisions and file revisions are empty,
436 // restore everything. Otherwise, just restore the requested items.
437 $restoreAll = empty( $timestamps ) && empty( $fileVersions );
439 $restoreText = $restoreAll ||
!empty( $timestamps );
440 $restoreFiles = $restoreAll ||
!empty( $fileVersions );
442 if ( $restoreFiles && $this->title
->getNamespace() == NS_FILE
) {
443 $img = wfLocalFile( $this->title
);
444 $img->load( File
::READ_LATEST
);
445 $this->fileStatus
= $img->restore( $fileVersions, $unsuppress );
446 if ( !$this->fileStatus
->isOK() ) {
449 $filesRestored = $this->fileStatus
->successCount
;
454 if ( $restoreText ) {
455 $this->revisionStatus
= $this->undeleteRevisions( $timestamps, $unsuppress, $comment );
456 if ( !$this->revisionStatus
->isOK() ) {
460 $textRestored = $this->revisionStatus
->getValue();
467 if ( !$textRestored && !$filesRestored ) {
468 wfDebug( "Undelete: nothing undeleted...\n" );
473 if ( $user === null ) {
478 $logEntry = new ManualLogEntry( 'delete', 'restore' );
479 $logEntry->setPerformer( $user );
480 $logEntry->setTarget( $this->title
);
481 $logEntry->setComment( $comment );
482 $logEntry->setTags( $tags );
483 $logEntry->setParameters( [
485 'revisions' => $textRestored,
486 'files' => $filesRestored,
490 Hooks
::run( 'ArticleUndeleteLogEntry', [ $this, &$logEntry, $user ] );
492 $logid = $logEntry->insert();
493 $logEntry->publish( $logid );
495 return [ $textRestored, $filesRestored, $comment ];
499 * This is the meaty bit -- It restores archived revisions of the given page
500 * to the revision table.
502 * @param array $timestamps Pass an empty array to restore all revisions,
503 * otherwise list the ones to undelete.
504 * @param bool $unsuppress Remove all ar_deleted/fa_deleted restrictions of seletected revs
505 * @param string $comment
506 * @throws ReadOnlyError
507 * @return Status Status object containing the number of revisions restored on success
509 private function undeleteRevisions( $timestamps, $unsuppress = false, $comment = '' ) {
510 if ( wfReadOnly() ) {
511 throw new ReadOnlyError();
514 $dbw = wfGetDB( DB_MASTER
);
515 $dbw->startAtomic( __METHOD__
);
517 $restoreAll = empty( $timestamps );
519 # Does this page already exist? We'll have to update it...
520 $article = WikiPage
::factory( $this->title
);
521 # Load latest data for the current page (T33179)
522 $article->loadPageData( 'fromdbmaster' );
523 $oldcountable = $article->isCountable();
525 $page = $dbw->selectRow( 'page',
526 [ 'page_id', 'page_latest' ],
527 [ 'page_namespace' => $this->title
->getNamespace(),
528 'page_title' => $this->title
->getDBkey() ],
530 [ 'FOR UPDATE' ] // lock page
535 # Page already exists. Import the history, and if necessary
536 # we'll update the latest revision field in the record.
538 # Get the time span of this page
539 $previousTimestamp = $dbw->selectField( 'revision', 'rev_timestamp',
540 [ 'rev_id' => $page->page_latest
],
543 if ( $previousTimestamp === false ) {
544 wfDebug( __METHOD__
. ": existing page refers to a page_latest that does not exist\n" );
546 $status = Status
::newGood( 0 );
547 $status->warning( 'undeleterevision-missing' );
548 $dbw->endAtomic( __METHOD__
);
553 # Have to create a new article...
555 $previousTimestamp = 0;
559 'ar_namespace' => $this->title
->getNamespace(),
560 'ar_title' => $this->title
->getDBkey(),
562 if ( !$restoreAll ) {
563 $oldWhere['ar_timestamp'] = array_map( [ &$dbw, 'timestamp' ], $timestamps );
566 $commentQuery = CommentStore
::newKey( 'ar_comment' )->getJoin();
568 $tables = [ 'archive', 'revision' ] +
$commentQuery['tables'];
585 ] +
$commentQuery['fields'];
587 if ( $this->config
->get( 'ContentHandlerUseDB' ) ) {
588 $fields[] = 'ar_content_format';
589 $fields[] = 'ar_content_model';
593 'revision' => [ 'LEFT JOIN', 'ar_rev_id=rev_id' ],
594 ] +
$commentQuery['joins'];
597 * Select each archived revision...
599 $result = $dbw->select(
605 [ 'ORDER BY' => 'ar_timestamp' ],
609 $rev_count = $result->numRows();
611 wfDebug( __METHOD__
. ": no revisions to restore\n" );
613 $status = Status
::newGood( 0 );
614 $status->warning( "undelete-no-results" );
615 $dbw->endAtomic( __METHOD__
);
620 // We use ar_id because there can be duplicate ar_rev_id even for the same
621 // page. In this case, we may be able to restore the first one.
622 $restoreFailedArIds = [];
624 // Map rev_id to the ar_id that is allowed to use it. When checking later,
625 // if it doesn't match, the current ar_id can not be restored.
627 // Value can be an ar_id or -1 (-1 means no ar_id can use it, since the
628 // rev_id is taken before we even start the restore).
629 $allowedRevIdToArIdMap = [];
631 $latestRestorableRow = null;
633 foreach ( $result as $row ) {
634 if ( $row->ar_rev_id
) {
635 // rev_id is taken even before we start restoring.
636 if ( $row->ar_rev_id
=== $row->rev_id
) {
637 $restoreFailedArIds[] = $row->ar_id
;
638 $allowedRevIdToArIdMap[$row->ar_rev_id
] = -1;
640 // rev_id is not taken yet in the DB, but it might be taken
641 // by a prior revision in the same restore operation. If
642 // not, we need to reserve it.
643 if ( isset( $allowedRevIdToArIdMap[$row->ar_rev_id
] ) ) {
644 $restoreFailedArIds[] = $row->ar_id
;
646 $allowedRevIdToArIdMap[$row->ar_rev_id
] = $row->ar_id
;
647 $latestRestorableRow = $row;
651 // If ar_rev_id is null, there can't be a collision, and a
652 // rev_id will be chosen automatically.
653 $latestRestorableRow = $row;
657 $result->seek( 0 ); // move back
660 if ( $latestRestorableRow !== null ) {
661 $oldPageId = (int)$latestRestorableRow->ar_page_id
; // pass this to ArticleUndelete hook
663 // grab the content to check consistency with global state before restoring the page.
664 $revision = Revision
::newFromArchiveRow( $latestRestorableRow,
666 'title' => $article->getTitle(), // used to derive default content model
669 $user = User
::newFromName( $revision->getUserText( Revision
::RAW
), false );
670 $content = $revision->getContent( Revision
::RAW
);
672 // NOTE: article ID may not be known yet. prepareSave() should not modify the database.
673 $status = $content->prepareSave( $article, 0, -1, $user );
674 if ( !$status->isOK() ) {
675 $dbw->endAtomic( __METHOD__
);
681 $newid = false; // newly created page ID
682 $restored = 0; // number of revisions restored
683 /** @var Revision $revision */
686 // If there are no restorable revisions, we can skip most of the steps.
687 if ( $latestRestorableRow === null ) {
688 $failedRevisionCount = $rev_count;
691 // Check the state of the newest to-be version...
693 && ( $latestRestorableRow->ar_deleted
& Revision
::DELETED_TEXT
)
695 $dbw->endAtomic( __METHOD__
);
697 return Status
::newFatal( "undeleterevdel" );
699 // Safe to insert now...
700 $newid = $article->insertOn( $dbw, $latestRestorableRow->ar_page_id
);
701 if ( $newid === false ) {
702 // The old ID is reserved; let's pick another
703 $newid = $article->insertOn( $dbw );
707 // Check if a deleted revision will become the current revision...
708 if ( $latestRestorableRow->ar_timestamp
> $previousTimestamp ) {
709 // Check the state of the newest to-be version...
711 && ( $latestRestorableRow->ar_deleted
& Revision
::DELETED_TEXT
)
713 $dbw->endAtomic( __METHOD__
);
715 return Status
::newFatal( "undeleterevdel" );
720 $pageId = $article->getId();
723 foreach ( $result as $row ) {
724 // Check for key dupes due to needed archive integrity.
725 if ( $row->ar_rev_id
&& $allowedRevIdToArIdMap[$row->ar_rev_id
] !== $row->ar_id
) {
728 // Insert one revision at a time...maintaining deletion status
729 // unless we are specifically removing all restrictions...
730 $revision = Revision
::newFromArchiveRow( $row,
733 'title' => $this->title
,
734 'deleted' => $unsuppress ?
0 : $row->ar_deleted
737 $revision->insertOn( $dbw );
739 // Also restore reference to the revision in ip_changes if it was an IP edit.
740 if ( (int)$row->ar_rev_id
=== 0 && IP
::isValid( $row->ar_user_text
) ) {
742 'ipc_rev_id' => $row->ar_rev_id
,
743 'ipc_rev_timestamp' => $row->ar_timestamp
,
744 'ipc_hex' => IP
::toHex( $row->ar_user_text
),
746 $dbw->insert( 'ip_changes', $ipcRow, __METHOD__
);
751 Hooks
::run( 'ArticleRevisionUndeleted',
752 [ &$this->title
, $revision, $row->ar_page_id
] );
753 $restoredPages[$row->ar_page_id
] = true;
756 // Now that it's safely stored, take it out of the archive
757 // Don't delete rows that we failed to restore
758 $toDeleteConds = $oldWhere;
759 $failedRevisionCount = count( $restoreFailedArIds );
760 if ( $failedRevisionCount > 0 ) {
761 $toDeleteConds[] = 'ar_id NOT IN ( ' . $dbw->makeList( $restoreFailedArIds ) . ' )';
764 $dbw->delete( 'archive',
769 $status = Status
::newGood( $restored );
771 if ( $failedRevisionCount > 0 ) {
773 wfMessage( 'undeleterevision-duplicate-revid', $failedRevisionCount ) );
776 // Was anything restored at all?
778 $created = (bool)$newid;
779 // Attach the latest revision to the page...
780 $wasnew = $article->updateIfNewerOn( $dbw, $revision );
781 if ( $created ||
$wasnew ) {
782 // Update site stats, link tables, etc
783 $article->doEditUpdates(
785 User
::newFromName( $revision->getUserText( Revision
::RAW
), false ),
787 'created' => $created,
788 'oldcountable' => $oldcountable,
794 Hooks
::run( 'ArticleUndelete',
795 [ &$this->title
, $created, $comment, $oldPageId, $restoredPages ] );
796 if ( $this->title
->getNamespace() == NS_FILE
) {
797 DeferredUpdates
::addUpdate( new HTMLCacheUpdate( $this->title
, 'imagelinks' ) );
801 $dbw->endAtomic( __METHOD__
);
809 public function getFileStatus() {
810 return $this->fileStatus
;
816 public function getRevisionStatus() {
817 return $this->revisionStatus
;