'PackedImageGallery' => __DIR__ . '/includes/gallery/PackedImageGallery.php',
'PackedOverlayImageGallery' => __DIR__ . '/includes/gallery/PackedOverlayImageGallery.php',
'Page' => __DIR__ . '/includes/page/Page.php',
- 'PageArchive' => __DIR__ . '/includes/page/PageAcrhive.php',
+ 'PageArchive' => __DIR__ . '/includes/page/PageArchive.php',
'PageExists' => __DIR__ . '/maintenance/pageExists.php',
'PageLangLogFormatter' => __DIR__ . '/includes/logging/PageLangLogFormatter.php',
'PageProps' => __DIR__ . '/includes/PageProps.php',
+++ /dev/null
-<?php
-/**
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- */
-
-use MediaWiki\MediaWikiServices;
-use Wikimedia\Rdbms\ResultWrapper;
-
-/**
- * Used to show archived pages and eventually restore them.
- */
-class PageArchive {
- /** @var Title */
- protected $title;
-
- /** @var Status */
- protected $fileStatus;
-
- /** @var Status */
- protected $revisionStatus;
-
- /** @var Config */
- protected $config;
-
- public function __construct( $title, Config $config = null ) {
- if ( is_null( $title ) ) {
- throw new MWException( __METHOD__ . ' given a null title.' );
- }
- $this->title = $title;
- if ( $config === null ) {
- wfDebug( __METHOD__ . ' did not have a Config object passed to it' );
- $config = MediaWikiServices::getInstance()->getMainConfig();
- }
- $this->config = $config;
- }
-
- public function doesWrites() {
- return true;
- }
-
- /**
- * List all deleted pages recorded in the archive table. Returns result
- * wrapper with (ar_namespace, ar_title, count) fields, ordered by page
- * namespace/title.
- *
- * @return ResultWrapper
- */
- public static function listAllPages() {
- $dbr = wfGetDB( DB_REPLICA );
-
- return self::listPages( $dbr, '' );
- }
-
- /**
- * List deleted pages recorded in the archive table matching the
- * given title prefix.
- * Returns result wrapper with (ar_namespace, ar_title, count) fields.
- *
- * @param string $prefix Title prefix
- * @return ResultWrapper
- */
- public static function listPagesByPrefix( $prefix ) {
- $dbr = wfGetDB( DB_REPLICA );
-
- $title = Title::newFromText( $prefix );
- if ( $title ) {
- $ns = $title->getNamespace();
- $prefix = $title->getDBkey();
- } else {
- // Prolly won't work too good
- // @todo handle bare namespace names cleanly?
- $ns = 0;
- }
-
- $conds = [
- 'ar_namespace' => $ns,
- 'ar_title' . $dbr->buildLike( $prefix, $dbr->anyString() ),
- ];
-
- return self::listPages( $dbr, $conds );
- }
-
- /**
- * @param IDatabase $dbr
- * @param string|array $condition
- * @return bool|ResultWrapper
- */
- protected static function listPages( $dbr, $condition ) {
- return $dbr->select(
- [ 'archive' ],
- [
- 'ar_namespace',
- 'ar_title',
- 'count' => 'COUNT(*)'
- ],
- $condition,
- __METHOD__,
- [
- 'GROUP BY' => [ 'ar_namespace', 'ar_title' ],
- 'ORDER BY' => [ 'ar_namespace', 'ar_title' ],
- 'LIMIT' => 100,
- ]
- );
- }
-
- /**
- * List the revisions of the given page. Returns result wrapper with
- * (ar_minor_edit, ar_timestamp, ar_user, ar_user_text, ar_comment) fields.
- *
- * @return ResultWrapper
- */
- public function listRevisions() {
- $dbr = wfGetDB( DB_REPLICA );
-
- $tables = [ 'archive' ];
-
- $fields = [
- 'ar_minor_edit', 'ar_timestamp', 'ar_user', 'ar_user_text',
- 'ar_comment', 'ar_len', 'ar_deleted', 'ar_rev_id', 'ar_sha1',
- ];
-
- if ( $this->config->get( 'ContentHandlerUseDB' ) ) {
- $fields[] = 'ar_content_format';
- $fields[] = 'ar_content_model';
- }
-
- $conds = [ 'ar_namespace' => $this->title->getNamespace(),
- 'ar_title' => $this->title->getDBkey() ];
-
- $options = [ 'ORDER BY' => 'ar_timestamp DESC' ];
-
- $join_conds = [];
-
- ChangeTags::modifyDisplayQuery(
- $tables,
- $fields,
- $conds,
- $join_conds,
- $options,
- ''
- );
-
- return $dbr->select( $tables,
- $fields,
- $conds,
- __METHOD__,
- $options,
- $join_conds
- );
- }
-
- /**
- * List the deleted file revisions for this page, if it's a file page.
- * Returns a result wrapper with various filearchive fields, or null
- * if not a file page.
- *
- * @return ResultWrapper
- * @todo Does this belong in Image for fuller encapsulation?
- */
- public function listFiles() {
- if ( $this->title->getNamespace() != NS_FILE ) {
- return null;
- }
-
- $dbr = wfGetDB( DB_REPLICA );
- return $dbr->select(
- 'filearchive',
- ArchivedFile::selectFields(),
- [ 'fa_name' => $this->title->getDBkey() ],
- __METHOD__,
- [ 'ORDER BY' => 'fa_timestamp DESC' ]
- );
- }
-
- /**
- * Return a Revision object containing data for the deleted revision.
- * Note that the result *may* or *may not* have a null page ID.
- *
- * @param string $timestamp
- * @return Revision|null
- */
- public function getRevision( $timestamp ) {
- $dbr = wfGetDB( DB_REPLICA );
-
- $fields = [
- 'ar_rev_id',
- 'ar_text',
- 'ar_comment',
- 'ar_user',
- 'ar_user_text',
- 'ar_timestamp',
- 'ar_minor_edit',
- 'ar_flags',
- 'ar_text_id',
- 'ar_deleted',
- 'ar_len',
- 'ar_sha1',
- ];
-
- if ( $this->config->get( 'ContentHandlerUseDB' ) ) {
- $fields[] = 'ar_content_format';
- $fields[] = 'ar_content_model';
- }
-
- $row = $dbr->selectRow( 'archive',
- $fields,
- [ 'ar_namespace' => $this->title->getNamespace(),
- 'ar_title' => $this->title->getDBkey(),
- 'ar_timestamp' => $dbr->timestamp( $timestamp ) ],
- __METHOD__ );
-
- if ( $row ) {
- return Revision::newFromArchiveRow( $row, [ 'title' => $this->title ] );
- }
-
- return null;
- }
-
- /**
- * Return the most-previous revision, either live or deleted, against
- * the deleted revision given by timestamp.
- *
- * May produce unexpected results in case of history merges or other
- * unusual time issues.
- *
- * @param string $timestamp
- * @return Revision|null Null when there is no previous revision
- */
- public function getPreviousRevision( $timestamp ) {
- $dbr = wfGetDB( DB_REPLICA );
-
- // Check the previous deleted revision...
- $row = $dbr->selectRow( 'archive',
- 'ar_timestamp',
- [ 'ar_namespace' => $this->title->getNamespace(),
- 'ar_title' => $this->title->getDBkey(),
- 'ar_timestamp < ' .
- $dbr->addQuotes( $dbr->timestamp( $timestamp ) ) ],
- __METHOD__,
- [
- 'ORDER BY' => 'ar_timestamp DESC',
- 'LIMIT' => 1 ] );
- $prevDeleted = $row ? wfTimestamp( TS_MW, $row->ar_timestamp ) : false;
-
- $row = $dbr->selectRow( [ 'page', 'revision' ],
- [ 'rev_id', 'rev_timestamp' ],
- [
- 'page_namespace' => $this->title->getNamespace(),
- 'page_title' => $this->title->getDBkey(),
- 'page_id = rev_page',
- 'rev_timestamp < ' .
- $dbr->addQuotes( $dbr->timestamp( $timestamp ) ) ],
- __METHOD__,
- [
- 'ORDER BY' => 'rev_timestamp DESC',
- 'LIMIT' => 1 ] );
- $prevLive = $row ? wfTimestamp( TS_MW, $row->rev_timestamp ) : false;
- $prevLiveId = $row ? intval( $row->rev_id ) : null;
-
- if ( $prevLive && $prevLive > $prevDeleted ) {
- // Most prior revision was live
- return Revision::newFromId( $prevLiveId );
- } elseif ( $prevDeleted ) {
- // Most prior revision was deleted
- return $this->getRevision( $prevDeleted );
- }
-
- // No prior revision on this page.
- return null;
- }
-
- /**
- * Get the text from an archive row containing ar_text, ar_flags and ar_text_id
- *
- * @param object $row Database row
- * @return string
- */
- public function getTextFromRow( $row ) {
- if ( is_null( $row->ar_text_id ) ) {
- // An old row from MediaWiki 1.4 or previous.
- // Text is embedded in this row in classic compression format.
- return Revision::getRevisionText( $row, 'ar_' );
- }
-
- // New-style: keyed to the text storage backend.
- $dbr = wfGetDB( DB_REPLICA );
- $text = $dbr->selectRow( 'text',
- [ 'old_text', 'old_flags' ],
- [ 'old_id' => $row->ar_text_id ],
- __METHOD__ );
-
- return Revision::getRevisionText( $text );
- }
-
- /**
- * Fetch (and decompress if necessary) the stored text of the most
- * recently edited deleted revision of the page.
- *
- * If there are no archived revisions for the page, returns NULL.
- *
- * @return string|null
- */
- public function getLastRevisionText() {
- $dbr = wfGetDB( DB_REPLICA );
- $row = $dbr->selectRow( 'archive',
- [ 'ar_text', 'ar_flags', 'ar_text_id' ],
- [ 'ar_namespace' => $this->title->getNamespace(),
- 'ar_title' => $this->title->getDBkey() ],
- __METHOD__,
- [ 'ORDER BY' => 'ar_timestamp DESC' ] );
-
- if ( $row ) {
- return $this->getTextFromRow( $row );
- }
-
- return null;
- }
-
- /**
- * Quick check if any archived revisions are present for the page.
- *
- * @return bool
- */
- public function isDeleted() {
- $dbr = wfGetDB( DB_REPLICA );
- $n = $dbr->selectField( 'archive', 'COUNT(ar_title)',
- [ 'ar_namespace' => $this->title->getNamespace(),
- 'ar_title' => $this->title->getDBkey() ],
- __METHOD__
- );
-
- return ( $n > 0 );
- }
-
- /**
- * Restore the given (or all) text and file revisions for the page.
- * Once restored, the items will be removed from the archive tables.
- * The deletion log will be updated with an undeletion notice.
- *
- * This also sets Status objects, $this->fileStatus and $this->revisionStatus
- * (depending what operations are attempted).
- *
- * @param array $timestamps Pass an empty array to restore all revisions,
- * otherwise list the ones to undelete.
- * @param string $comment
- * @param array $fileVersions
- * @param bool $unsuppress
- * @param User $user User performing the action, or null to use $wgUser
- * @param string|string[] $tags Change tags to add to log entry
- * ($user should be able to add the specified tags before this is called)
- * @return array|bool array(number of file revisions restored, number of image revisions
- * restored, log message) on success, false on failure.
- */
- public function undelete( $timestamps, $comment = '', $fileVersions = [],
- $unsuppress = false, User $user = null, $tags = null
- ) {
- // If both the set of text revisions and file revisions are empty,
- // restore everything. Otherwise, just restore the requested items.
- $restoreAll = empty( $timestamps ) && empty( $fileVersions );
-
- $restoreText = $restoreAll || !empty( $timestamps );
- $restoreFiles = $restoreAll || !empty( $fileVersions );
-
- if ( $restoreFiles && $this->title->getNamespace() == NS_FILE ) {
- $img = wfLocalFile( $this->title );
- $img->load( File::READ_LATEST );
- $this->fileStatus = $img->restore( $fileVersions, $unsuppress );
- if ( !$this->fileStatus->isOK() ) {
- return false;
- }
- $filesRestored = $this->fileStatus->successCount;
- } else {
- $filesRestored = 0;
- }
-
- if ( $restoreText ) {
- $this->revisionStatus = $this->undeleteRevisions( $timestamps, $unsuppress, $comment );
- if ( !$this->revisionStatus->isOK() ) {
- return false;
- }
-
- $textRestored = $this->revisionStatus->getValue();
- } else {
- $textRestored = 0;
- }
-
- // Touch the log!
-
- if ( $textRestored && $filesRestored ) {
- $reason = wfMessage( 'undeletedrevisions-files' )
- ->numParams( $textRestored, $filesRestored )->inContentLanguage()->text();
- } elseif ( $textRestored ) {
- $reason = wfMessage( 'undeletedrevisions' )->numParams( $textRestored )
- ->inContentLanguage()->text();
- } elseif ( $filesRestored ) {
- $reason = wfMessage( 'undeletedfiles' )->numParams( $filesRestored )
- ->inContentLanguage()->text();
- } else {
- wfDebug( "Undelete: nothing undeleted...\n" );
-
- return false;
- }
-
- if ( trim( $comment ) != '' ) {
- $reason .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $comment;
- }
-
- if ( $user === null ) {
- global $wgUser;
- $user = $wgUser;
- }
-
- $logEntry = new ManualLogEntry( 'delete', 'restore' );
- $logEntry->setPerformer( $user );
- $logEntry->setTarget( $this->title );
- $logEntry->setComment( $reason );
- $logEntry->setTags( $tags );
-
- Hooks::run( 'ArticleUndeleteLogEntry', [ $this, &$logEntry, $user ] );
-
- $logid = $logEntry->insert();
- $logEntry->publish( $logid );
-
- return [ $textRestored, $filesRestored, $reason ];
- }
-
- /**
- * This is the meaty bit -- It restores archived revisions of the given page
- * to the revision table.
- *
- * @param array $timestamps Pass an empty array to restore all revisions,
- * otherwise list the ones to undelete.
- * @param bool $unsuppress Remove all ar_deleted/fa_deleted restrictions of seletected revs
- * @param string $comment
- * @throws ReadOnlyError
- * @return Status Status object containing the number of revisions restored on success
- */
- private function undeleteRevisions( $timestamps, $unsuppress = false, $comment = '' ) {
- if ( wfReadOnly() ) {
- throw new ReadOnlyError();
- }
-
- $dbw = wfGetDB( DB_MASTER );
- $dbw->startAtomic( __METHOD__ );
-
- $restoreAll = empty( $timestamps );
-
- # Does this page already exist? We'll have to update it...
- $article = WikiPage::factory( $this->title );
- # Load latest data for the current page (T33179)
- $article->loadPageData( 'fromdbmaster' );
- $oldcountable = $article->isCountable();
-
- $page = $dbw->selectRow( 'page',
- [ 'page_id', 'page_latest' ],
- [ 'page_namespace' => $this->title->getNamespace(),
- 'page_title' => $this->title->getDBkey() ],
- __METHOD__,
- [ 'FOR UPDATE' ] // lock page
- );
-
- if ( $page ) {
- $makepage = false;
- # Page already exists. Import the history, and if necessary
- # we'll update the latest revision field in the record.
-
- # Get the time span of this page
- $previousTimestamp = $dbw->selectField( 'revision', 'rev_timestamp',
- [ 'rev_id' => $page->page_latest ],
- __METHOD__ );
-
- if ( $previousTimestamp === false ) {
- wfDebug( __METHOD__ . ": existing page refers to a page_latest that does not exist\n" );
-
- $status = Status::newGood( 0 );
- $status->warning( 'undeleterevision-missing' );
- $dbw->endAtomic( __METHOD__ );
-
- return $status;
- }
- } else {
- # Have to create a new article...
- $makepage = true;
- $previousTimestamp = 0;
- }
-
- $oldWhere = [
- 'ar_namespace' => $this->title->getNamespace(),
- 'ar_title' => $this->title->getDBkey(),
- ];
- if ( !$restoreAll ) {
- $oldWhere['ar_timestamp'] = array_map( [ &$dbw, 'timestamp' ], $timestamps );
- }
-
- $fields = [
- 'ar_id',
- 'ar_rev_id',
- 'rev_id',
- 'ar_text',
- 'ar_comment',
- 'ar_user',
- 'ar_user_text',
- 'ar_timestamp',
- 'ar_minor_edit',
- 'ar_flags',
- 'ar_text_id',
- 'ar_deleted',
- 'ar_page_id',
- 'ar_len',
- 'ar_sha1'
- ];
-
- if ( $this->config->get( 'ContentHandlerUseDB' ) ) {
- $fields[] = 'ar_content_format';
- $fields[] = 'ar_content_model';
- }
-
- /**
- * Select each archived revision...
- */
- $result = $dbw->select(
- [ 'archive', 'revision' ],
- $fields,
- $oldWhere,
- __METHOD__,
- /* options */
- [ 'ORDER BY' => 'ar_timestamp' ],
- [ 'revision' => [ 'LEFT JOIN', 'ar_rev_id=rev_id' ] ]
- );
-
- $rev_count = $result->numRows();
- if ( !$rev_count ) {
- wfDebug( __METHOD__ . ": no revisions to restore\n" );
-
- $status = Status::newGood( 0 );
- $status->warning( "undelete-no-results" );
- $dbw->endAtomic( __METHOD__ );
-
- return $status;
- }
-
- // We use ar_id because there can be duplicate ar_rev_id even for the same
- // page. In this case, we may be able to restore the first one.
- $restoreFailedArIds = [];
-
- // Map rev_id to the ar_id that is allowed to use it. When checking later,
- // if it doesn't match, the current ar_id can not be restored.
-
- // Value can be an ar_id or -1 (-1 means no ar_id can use it, since the
- // rev_id is taken before we even start the restore).
- $allowedRevIdToArIdMap = [];
-
- $latestRestorableRow = null;
-
- foreach ( $result as $row ) {
- if ( $row->ar_rev_id ) {
- // rev_id is taken even before we start restoring.
- if ( $row->ar_rev_id === $row->rev_id ) {
- $restoreFailedArIds[] = $row->ar_id;
- $allowedRevIdToArIdMap[$row->ar_rev_id] = -1;
- } else {
- // rev_id is not taken yet in the DB, but it might be taken
- // by a prior revision in the same restore operation. If
- // not, we need to reserve it.
- if ( isset( $allowedRevIdToArIdMap[$row->ar_rev_id] ) ) {
- $restoreFailedArIds[] = $row->ar_id;
- } else {
- $allowedRevIdToArIdMap[$row->ar_rev_id] = $row->ar_id;
- $latestRestorableRow = $row;
- }
- }
- } else {
- // If ar_rev_id is null, there can't be a collision, and a
- // rev_id will be chosen automatically.
- $latestRestorableRow = $row;
- }
- }
-
- $result->seek( 0 ); // move back
-
- $oldPageId = 0;
- if ( $latestRestorableRow !== null ) {
- $oldPageId = (int)$latestRestorableRow->ar_page_id; // pass this to ArticleUndelete hook
-
- // grab the content to check consistency with global state before restoring the page.
- $revision = Revision::newFromArchiveRow( $latestRestorableRow,
- [
- 'title' => $article->getTitle(), // used to derive default content model
- ]
- );
- $user = User::newFromName( $revision->getUserText( Revision::RAW ), false );
- $content = $revision->getContent( Revision::RAW );
-
- // NOTE: article ID may not be known yet. prepareSave() should not modify the database.
- $status = $content->prepareSave( $article, 0, -1, $user );
- if ( !$status->isOK() ) {
- $dbw->endAtomic( __METHOD__ );
-
- return $status;
- }
- }
-
- $newid = false; // newly created page ID
- $restored = 0; // number of revisions restored
- /** @var Revision $revision */
- $revision = null;
-
- // If there are no restorable revisions, we can skip most of the steps.
- if ( $latestRestorableRow === null ) {
- $failedRevisionCount = $rev_count;
- } else {
- if ( $makepage ) {
- // Check the state of the newest to-be version...
- if ( !$unsuppress
- && ( $latestRestorableRow->ar_deleted & Revision::DELETED_TEXT )
- ) {
- $dbw->endAtomic( __METHOD__ );
-
- return Status::newFatal( "undeleterevdel" );
- }
- // Safe to insert now...
- $newid = $article->insertOn( $dbw, $latestRestorableRow->ar_page_id );
- if ( $newid === false ) {
- // The old ID is reserved; let's pick another
- $newid = $article->insertOn( $dbw );
- }
- $pageId = $newid;
- } else {
- // Check if a deleted revision will become the current revision...
- if ( $latestRestorableRow->ar_timestamp > $previousTimestamp ) {
- // Check the state of the newest to-be version...
- if ( !$unsuppress
- && ( $latestRestorableRow->ar_deleted & Revision::DELETED_TEXT )
- ) {
- $dbw->endAtomic( __METHOD__ );
-
- return Status::newFatal( "undeleterevdel" );
- }
- }
-
- $newid = false;
- $pageId = $article->getId();
- }
-
- foreach ( $result as $row ) {
- // Check for key dupes due to needed archive integrity.
- if ( $row->ar_rev_id && $allowedRevIdToArIdMap[$row->ar_rev_id] !== $row->ar_id ) {
- continue;
- }
- // Insert one revision at a time...maintaining deletion status
- // unless we are specifically removing all restrictions...
- $revision = Revision::newFromArchiveRow( $row,
- [
- 'page' => $pageId,
- 'title' => $this->title,
- 'deleted' => $unsuppress ? 0 : $row->ar_deleted
- ] );
-
- $revision->insertOn( $dbw );
- $restored++;
-
- Hooks::run( 'ArticleRevisionUndeleted',
- [ &$this->title, $revision, $row->ar_page_id ] );
- }
-
- // Now that it's safely stored, take it out of the archive
- // Don't delete rows that we failed to restore
- $toDeleteConds = $oldWhere;
- $failedRevisionCount = count( $restoreFailedArIds );
- if ( $failedRevisionCount > 0 ) {
- $toDeleteConds[] = 'ar_id NOT IN ( ' . $dbw->makeList( $restoreFailedArIds ) . ' )';
- }
-
- $dbw->delete( 'archive',
- $toDeleteConds,
- __METHOD__ );
- }
-
- $status = Status::newGood( $restored );
-
- if ( $failedRevisionCount > 0 ) {
- $status->warning(
- wfMessage( 'undeleterevision-duplicate-revid', $failedRevisionCount ) );
- }
-
- // Was anything restored at all?
- if ( $restored ) {
- $created = (bool)$newid;
- // Attach the latest revision to the page...
- $wasnew = $article->updateIfNewerOn( $dbw, $revision );
- if ( $created || $wasnew ) {
- // Update site stats, link tables, etc
- $article->doEditUpdates(
- $revision,
- User::newFromName( $revision->getUserText( Revision::RAW ), false ),
- [
- 'created' => $created,
- 'oldcountable' => $oldcountable,
- 'restored' => true
- ]
- );
- }
-
- Hooks::run( 'ArticleUndelete', [ &$this->title, $created, $comment, $oldPageId ] );
- if ( $this->title->getNamespace() == NS_FILE ) {
- DeferredUpdates::addUpdate( new HTMLCacheUpdate( $this->title, 'imagelinks' ) );
- }
- }
-
- $dbw->endAtomic( __METHOD__ );
-
- return $status;
- }
-
- /**
- * @return Status
- */
- public function getFileStatus() {
- return $this->fileStatus;
- }
-
- /**
- * @return Status
- */
- public function getRevisionStatus() {
- return $this->revisionStatus;
- }
-}
--- /dev/null
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\ResultWrapper;
+
+/**
+ * Used to show archived pages and eventually restore them.
+ */
+class PageArchive {
+ /** @var Title */
+ protected $title;
+
+ /** @var Status */
+ protected $fileStatus;
+
+ /** @var Status */
+ protected $revisionStatus;
+
+ /** @var Config */
+ protected $config;
+
+ public function __construct( $title, Config $config = null ) {
+ if ( is_null( $title ) ) {
+ throw new MWException( __METHOD__ . ' given a null title.' );
+ }
+ $this->title = $title;
+ if ( $config === null ) {
+ wfDebug( __METHOD__ . ' did not have a Config object passed to it' );
+ $config = MediaWikiServices::getInstance()->getMainConfig();
+ }
+ $this->config = $config;
+ }
+
+ public function doesWrites() {
+ return true;
+ }
+
+ /**
+ * List all deleted pages recorded in the archive table. Returns result
+ * wrapper with (ar_namespace, ar_title, count) fields, ordered by page
+ * namespace/title.
+ *
+ * @return ResultWrapper
+ */
+ public static function listAllPages() {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ return self::listPages( $dbr, '' );
+ }
+
+ /**
+ * List deleted pages recorded in the archive table matching the
+ * given title prefix.
+ * Returns result wrapper with (ar_namespace, ar_title, count) fields.
+ *
+ * @param string $prefix Title prefix
+ * @return ResultWrapper
+ */
+ public static function listPagesByPrefix( $prefix ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $title = Title::newFromText( $prefix );
+ if ( $title ) {
+ $ns = $title->getNamespace();
+ $prefix = $title->getDBkey();
+ } else {
+ // Prolly won't work too good
+ // @todo handle bare namespace names cleanly?
+ $ns = 0;
+ }
+
+ $conds = [
+ 'ar_namespace' => $ns,
+ 'ar_title' . $dbr->buildLike( $prefix, $dbr->anyString() ),
+ ];
+
+ return self::listPages( $dbr, $conds );
+ }
+
+ /**
+ * @param IDatabase $dbr
+ * @param string|array $condition
+ * @return bool|ResultWrapper
+ */
+ protected static function listPages( $dbr, $condition ) {
+ return $dbr->select(
+ [ 'archive' ],
+ [
+ 'ar_namespace',
+ 'ar_title',
+ 'count' => 'COUNT(*)'
+ ],
+ $condition,
+ __METHOD__,
+ [
+ 'GROUP BY' => [ 'ar_namespace', 'ar_title' ],
+ 'ORDER BY' => [ 'ar_namespace', 'ar_title' ],
+ 'LIMIT' => 100,
+ ]
+ );
+ }
+
+ /**
+ * List the revisions of the given page. Returns result wrapper with
+ * (ar_minor_edit, ar_timestamp, ar_user, ar_user_text, ar_comment) fields.
+ *
+ * @return ResultWrapper
+ */
+ public function listRevisions() {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $tables = [ 'archive' ];
+
+ $fields = [
+ 'ar_minor_edit', 'ar_timestamp', 'ar_user', 'ar_user_text',
+ 'ar_comment', 'ar_len', 'ar_deleted', 'ar_rev_id', 'ar_sha1',
+ ];
+
+ if ( $this->config->get( 'ContentHandlerUseDB' ) ) {
+ $fields[] = 'ar_content_format';
+ $fields[] = 'ar_content_model';
+ }
+
+ $conds = [ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey() ];
+
+ $options = [ 'ORDER BY' => 'ar_timestamp DESC' ];
+
+ $join_conds = [];
+
+ ChangeTags::modifyDisplayQuery(
+ $tables,
+ $fields,
+ $conds,
+ $join_conds,
+ $options,
+ ''
+ );
+
+ return $dbr->select( $tables,
+ $fields,
+ $conds,
+ __METHOD__,
+ $options,
+ $join_conds
+ );
+ }
+
+ /**
+ * List the deleted file revisions for this page, if it's a file page.
+ * Returns a result wrapper with various filearchive fields, or null
+ * if not a file page.
+ *
+ * @return ResultWrapper
+ * @todo Does this belong in Image for fuller encapsulation?
+ */
+ public function listFiles() {
+ if ( $this->title->getNamespace() != NS_FILE ) {
+ return null;
+ }
+
+ $dbr = wfGetDB( DB_REPLICA );
+ return $dbr->select(
+ 'filearchive',
+ ArchivedFile::selectFields(),
+ [ 'fa_name' => $this->title->getDBkey() ],
+ __METHOD__,
+ [ 'ORDER BY' => 'fa_timestamp DESC' ]
+ );
+ }
+
+ /**
+ * Return a Revision object containing data for the deleted revision.
+ * Note that the result *may* or *may not* have a null page ID.
+ *
+ * @param string $timestamp
+ * @return Revision|null
+ */
+ public function getRevision( $timestamp ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ $fields = [
+ 'ar_rev_id',
+ 'ar_text',
+ 'ar_comment',
+ 'ar_user',
+ 'ar_user_text',
+ 'ar_timestamp',
+ 'ar_minor_edit',
+ 'ar_flags',
+ 'ar_text_id',
+ 'ar_deleted',
+ 'ar_len',
+ 'ar_sha1',
+ ];
+
+ if ( $this->config->get( 'ContentHandlerUseDB' ) ) {
+ $fields[] = 'ar_content_format';
+ $fields[] = 'ar_content_model';
+ }
+
+ $row = $dbr->selectRow( 'archive',
+ $fields,
+ [ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey(),
+ 'ar_timestamp' => $dbr->timestamp( $timestamp ) ],
+ __METHOD__ );
+
+ if ( $row ) {
+ return Revision::newFromArchiveRow( $row, [ 'title' => $this->title ] );
+ }
+
+ return null;
+ }
+
+ /**
+ * Return the most-previous revision, either live or deleted, against
+ * the deleted revision given by timestamp.
+ *
+ * May produce unexpected results in case of history merges or other
+ * unusual time issues.
+ *
+ * @param string $timestamp
+ * @return Revision|null Null when there is no previous revision
+ */
+ public function getPreviousRevision( $timestamp ) {
+ $dbr = wfGetDB( DB_REPLICA );
+
+ // Check the previous deleted revision...
+ $row = $dbr->selectRow( 'archive',
+ 'ar_timestamp',
+ [ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey(),
+ 'ar_timestamp < ' .
+ $dbr->addQuotes( $dbr->timestamp( $timestamp ) ) ],
+ __METHOD__,
+ [
+ 'ORDER BY' => 'ar_timestamp DESC',
+ 'LIMIT' => 1 ] );
+ $prevDeleted = $row ? wfTimestamp( TS_MW, $row->ar_timestamp ) : false;
+
+ $row = $dbr->selectRow( [ 'page', 'revision' ],
+ [ 'rev_id', 'rev_timestamp' ],
+ [
+ 'page_namespace' => $this->title->getNamespace(),
+ 'page_title' => $this->title->getDBkey(),
+ 'page_id = rev_page',
+ 'rev_timestamp < ' .
+ $dbr->addQuotes( $dbr->timestamp( $timestamp ) ) ],
+ __METHOD__,
+ [
+ 'ORDER BY' => 'rev_timestamp DESC',
+ 'LIMIT' => 1 ] );
+ $prevLive = $row ? wfTimestamp( TS_MW, $row->rev_timestamp ) : false;
+ $prevLiveId = $row ? intval( $row->rev_id ) : null;
+
+ if ( $prevLive && $prevLive > $prevDeleted ) {
+ // Most prior revision was live
+ return Revision::newFromId( $prevLiveId );
+ } elseif ( $prevDeleted ) {
+ // Most prior revision was deleted
+ return $this->getRevision( $prevDeleted );
+ }
+
+ // No prior revision on this page.
+ return null;
+ }
+
+ /**
+ * Get the text from an archive row containing ar_text, ar_flags and ar_text_id
+ *
+ * @param object $row Database row
+ * @return string
+ */
+ public function getTextFromRow( $row ) {
+ if ( is_null( $row->ar_text_id ) ) {
+ // An old row from MediaWiki 1.4 or previous.
+ // Text is embedded in this row in classic compression format.
+ return Revision::getRevisionText( $row, 'ar_' );
+ }
+
+ // New-style: keyed to the text storage backend.
+ $dbr = wfGetDB( DB_REPLICA );
+ $text = $dbr->selectRow( 'text',
+ [ 'old_text', 'old_flags' ],
+ [ 'old_id' => $row->ar_text_id ],
+ __METHOD__ );
+
+ return Revision::getRevisionText( $text );
+ }
+
+ /**
+ * Fetch (and decompress if necessary) the stored text of the most
+ * recently edited deleted revision of the page.
+ *
+ * If there are no archived revisions for the page, returns NULL.
+ *
+ * @return string|null
+ */
+ public function getLastRevisionText() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $row = $dbr->selectRow( 'archive',
+ [ 'ar_text', 'ar_flags', 'ar_text_id' ],
+ [ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey() ],
+ __METHOD__,
+ [ 'ORDER BY' => 'ar_timestamp DESC' ] );
+
+ if ( $row ) {
+ return $this->getTextFromRow( $row );
+ }
+
+ return null;
+ }
+
+ /**
+ * Quick check if any archived revisions are present for the page.
+ *
+ * @return bool
+ */
+ public function isDeleted() {
+ $dbr = wfGetDB( DB_REPLICA );
+ $n = $dbr->selectField( 'archive', 'COUNT(ar_title)',
+ [ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey() ],
+ __METHOD__
+ );
+
+ return ( $n > 0 );
+ }
+
+ /**
+ * Restore the given (or all) text and file revisions for the page.
+ * Once restored, the items will be removed from the archive tables.
+ * The deletion log will be updated with an undeletion notice.
+ *
+ * This also sets Status objects, $this->fileStatus and $this->revisionStatus
+ * (depending what operations are attempted).
+ *
+ * @param array $timestamps Pass an empty array to restore all revisions,
+ * otherwise list the ones to undelete.
+ * @param string $comment
+ * @param array $fileVersions
+ * @param bool $unsuppress
+ * @param User $user User performing the action, or null to use $wgUser
+ * @param string|string[] $tags Change tags to add to log entry
+ * ($user should be able to add the specified tags before this is called)
+ * @return array|bool array(number of file revisions restored, number of image revisions
+ * restored, log message) on success, false on failure.
+ */
+ public function undelete( $timestamps, $comment = '', $fileVersions = [],
+ $unsuppress = false, User $user = null, $tags = null
+ ) {
+ // If both the set of text revisions and file revisions are empty,
+ // restore everything. Otherwise, just restore the requested items.
+ $restoreAll = empty( $timestamps ) && empty( $fileVersions );
+
+ $restoreText = $restoreAll || !empty( $timestamps );
+ $restoreFiles = $restoreAll || !empty( $fileVersions );
+
+ if ( $restoreFiles && $this->title->getNamespace() == NS_FILE ) {
+ $img = wfLocalFile( $this->title );
+ $img->load( File::READ_LATEST );
+ $this->fileStatus = $img->restore( $fileVersions, $unsuppress );
+ if ( !$this->fileStatus->isOK() ) {
+ return false;
+ }
+ $filesRestored = $this->fileStatus->successCount;
+ } else {
+ $filesRestored = 0;
+ }
+
+ if ( $restoreText ) {
+ $this->revisionStatus = $this->undeleteRevisions( $timestamps, $unsuppress, $comment );
+ if ( !$this->revisionStatus->isOK() ) {
+ return false;
+ }
+
+ $textRestored = $this->revisionStatus->getValue();
+ } else {
+ $textRestored = 0;
+ }
+
+ // Touch the log!
+
+ if ( $textRestored && $filesRestored ) {
+ $reason = wfMessage( 'undeletedrevisions-files' )
+ ->numParams( $textRestored, $filesRestored )->inContentLanguage()->text();
+ } elseif ( $textRestored ) {
+ $reason = wfMessage( 'undeletedrevisions' )->numParams( $textRestored )
+ ->inContentLanguage()->text();
+ } elseif ( $filesRestored ) {
+ $reason = wfMessage( 'undeletedfiles' )->numParams( $filesRestored )
+ ->inContentLanguage()->text();
+ } else {
+ wfDebug( "Undelete: nothing undeleted...\n" );
+
+ return false;
+ }
+
+ if ( trim( $comment ) != '' ) {
+ $reason .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $comment;
+ }
+
+ if ( $user === null ) {
+ global $wgUser;
+ $user = $wgUser;
+ }
+
+ $logEntry = new ManualLogEntry( 'delete', 'restore' );
+ $logEntry->setPerformer( $user );
+ $logEntry->setTarget( $this->title );
+ $logEntry->setComment( $reason );
+ $logEntry->setTags( $tags );
+
+ Hooks::run( 'ArticleUndeleteLogEntry', [ $this, &$logEntry, $user ] );
+
+ $logid = $logEntry->insert();
+ $logEntry->publish( $logid );
+
+ return [ $textRestored, $filesRestored, $reason ];
+ }
+
+ /**
+ * This is the meaty bit -- It restores archived revisions of the given page
+ * to the revision table.
+ *
+ * @param array $timestamps Pass an empty array to restore all revisions,
+ * otherwise list the ones to undelete.
+ * @param bool $unsuppress Remove all ar_deleted/fa_deleted restrictions of seletected revs
+ * @param string $comment
+ * @throws ReadOnlyError
+ * @return Status Status object containing the number of revisions restored on success
+ */
+ private function undeleteRevisions( $timestamps, $unsuppress = false, $comment = '' ) {
+ if ( wfReadOnly() ) {
+ throw new ReadOnlyError();
+ }
+
+ $dbw = wfGetDB( DB_MASTER );
+ $dbw->startAtomic( __METHOD__ );
+
+ $restoreAll = empty( $timestamps );
+
+ # Does this page already exist? We'll have to update it...
+ $article = WikiPage::factory( $this->title );
+ # Load latest data for the current page (T33179)
+ $article->loadPageData( 'fromdbmaster' );
+ $oldcountable = $article->isCountable();
+
+ $page = $dbw->selectRow( 'page',
+ [ 'page_id', 'page_latest' ],
+ [ 'page_namespace' => $this->title->getNamespace(),
+ 'page_title' => $this->title->getDBkey() ],
+ __METHOD__,
+ [ 'FOR UPDATE' ] // lock page
+ );
+
+ if ( $page ) {
+ $makepage = false;
+ # Page already exists. Import the history, and if necessary
+ # we'll update the latest revision field in the record.
+
+ # Get the time span of this page
+ $previousTimestamp = $dbw->selectField( 'revision', 'rev_timestamp',
+ [ 'rev_id' => $page->page_latest ],
+ __METHOD__ );
+
+ if ( $previousTimestamp === false ) {
+ wfDebug( __METHOD__ . ": existing page refers to a page_latest that does not exist\n" );
+
+ $status = Status::newGood( 0 );
+ $status->warning( 'undeleterevision-missing' );
+ $dbw->endAtomic( __METHOD__ );
+
+ return $status;
+ }
+ } else {
+ # Have to create a new article...
+ $makepage = true;
+ $previousTimestamp = 0;
+ }
+
+ $oldWhere = [
+ 'ar_namespace' => $this->title->getNamespace(),
+ 'ar_title' => $this->title->getDBkey(),
+ ];
+ if ( !$restoreAll ) {
+ $oldWhere['ar_timestamp'] = array_map( [ &$dbw, 'timestamp' ], $timestamps );
+ }
+
+ $fields = [
+ 'ar_id',
+ 'ar_rev_id',
+ 'rev_id',
+ 'ar_text',
+ 'ar_comment',
+ 'ar_user',
+ 'ar_user_text',
+ 'ar_timestamp',
+ 'ar_minor_edit',
+ 'ar_flags',
+ 'ar_text_id',
+ 'ar_deleted',
+ 'ar_page_id',
+ 'ar_len',
+ 'ar_sha1'
+ ];
+
+ if ( $this->config->get( 'ContentHandlerUseDB' ) ) {
+ $fields[] = 'ar_content_format';
+ $fields[] = 'ar_content_model';
+ }
+
+ /**
+ * Select each archived revision...
+ */
+ $result = $dbw->select(
+ [ 'archive', 'revision' ],
+ $fields,
+ $oldWhere,
+ __METHOD__,
+ /* options */
+ [ 'ORDER BY' => 'ar_timestamp' ],
+ [ 'revision' => [ 'LEFT JOIN', 'ar_rev_id=rev_id' ] ]
+ );
+
+ $rev_count = $result->numRows();
+ if ( !$rev_count ) {
+ wfDebug( __METHOD__ . ": no revisions to restore\n" );
+
+ $status = Status::newGood( 0 );
+ $status->warning( "undelete-no-results" );
+ $dbw->endAtomic( __METHOD__ );
+
+ return $status;
+ }
+
+ // We use ar_id because there can be duplicate ar_rev_id even for the same
+ // page. In this case, we may be able to restore the first one.
+ $restoreFailedArIds = [];
+
+ // Map rev_id to the ar_id that is allowed to use it. When checking later,
+ // if it doesn't match, the current ar_id can not be restored.
+
+ // Value can be an ar_id or -1 (-1 means no ar_id can use it, since the
+ // rev_id is taken before we even start the restore).
+ $allowedRevIdToArIdMap = [];
+
+ $latestRestorableRow = null;
+
+ foreach ( $result as $row ) {
+ if ( $row->ar_rev_id ) {
+ // rev_id is taken even before we start restoring.
+ if ( $row->ar_rev_id === $row->rev_id ) {
+ $restoreFailedArIds[] = $row->ar_id;
+ $allowedRevIdToArIdMap[$row->ar_rev_id] = -1;
+ } else {
+ // rev_id is not taken yet in the DB, but it might be taken
+ // by a prior revision in the same restore operation. If
+ // not, we need to reserve it.
+ if ( isset( $allowedRevIdToArIdMap[$row->ar_rev_id] ) ) {
+ $restoreFailedArIds[] = $row->ar_id;
+ } else {
+ $allowedRevIdToArIdMap[$row->ar_rev_id] = $row->ar_id;
+ $latestRestorableRow = $row;
+ }
+ }
+ } else {
+ // If ar_rev_id is null, there can't be a collision, and a
+ // rev_id will be chosen automatically.
+ $latestRestorableRow = $row;
+ }
+ }
+
+ $result->seek( 0 ); // move back
+
+ $oldPageId = 0;
+ if ( $latestRestorableRow !== null ) {
+ $oldPageId = (int)$latestRestorableRow->ar_page_id; // pass this to ArticleUndelete hook
+
+ // grab the content to check consistency with global state before restoring the page.
+ $revision = Revision::newFromArchiveRow( $latestRestorableRow,
+ [
+ 'title' => $article->getTitle(), // used to derive default content model
+ ]
+ );
+ $user = User::newFromName( $revision->getUserText( Revision::RAW ), false );
+ $content = $revision->getContent( Revision::RAW );
+
+ // NOTE: article ID may not be known yet. prepareSave() should not modify the database.
+ $status = $content->prepareSave( $article, 0, -1, $user );
+ if ( !$status->isOK() ) {
+ $dbw->endAtomic( __METHOD__ );
+
+ return $status;
+ }
+ }
+
+ $newid = false; // newly created page ID
+ $restored = 0; // number of revisions restored
+ /** @var Revision $revision */
+ $revision = null;
+
+ // If there are no restorable revisions, we can skip most of the steps.
+ if ( $latestRestorableRow === null ) {
+ $failedRevisionCount = $rev_count;
+ } else {
+ if ( $makepage ) {
+ // Check the state of the newest to-be version...
+ if ( !$unsuppress
+ && ( $latestRestorableRow->ar_deleted & Revision::DELETED_TEXT )
+ ) {
+ $dbw->endAtomic( __METHOD__ );
+
+ return Status::newFatal( "undeleterevdel" );
+ }
+ // Safe to insert now...
+ $newid = $article->insertOn( $dbw, $latestRestorableRow->ar_page_id );
+ if ( $newid === false ) {
+ // The old ID is reserved; let's pick another
+ $newid = $article->insertOn( $dbw );
+ }
+ $pageId = $newid;
+ } else {
+ // Check if a deleted revision will become the current revision...
+ if ( $latestRestorableRow->ar_timestamp > $previousTimestamp ) {
+ // Check the state of the newest to-be version...
+ if ( !$unsuppress
+ && ( $latestRestorableRow->ar_deleted & Revision::DELETED_TEXT )
+ ) {
+ $dbw->endAtomic( __METHOD__ );
+
+ return Status::newFatal( "undeleterevdel" );
+ }
+ }
+
+ $newid = false;
+ $pageId = $article->getId();
+ }
+
+ foreach ( $result as $row ) {
+ // Check for key dupes due to needed archive integrity.
+ if ( $row->ar_rev_id && $allowedRevIdToArIdMap[$row->ar_rev_id] !== $row->ar_id ) {
+ continue;
+ }
+ // Insert one revision at a time...maintaining deletion status
+ // unless we are specifically removing all restrictions...
+ $revision = Revision::newFromArchiveRow( $row,
+ [
+ 'page' => $pageId,
+ 'title' => $this->title,
+ 'deleted' => $unsuppress ? 0 : $row->ar_deleted
+ ] );
+
+ $revision->insertOn( $dbw );
+ $restored++;
+
+ Hooks::run( 'ArticleRevisionUndeleted',
+ [ &$this->title, $revision, $row->ar_page_id ] );
+ }
+
+ // Now that it's safely stored, take it out of the archive
+ // Don't delete rows that we failed to restore
+ $toDeleteConds = $oldWhere;
+ $failedRevisionCount = count( $restoreFailedArIds );
+ if ( $failedRevisionCount > 0 ) {
+ $toDeleteConds[] = 'ar_id NOT IN ( ' . $dbw->makeList( $restoreFailedArIds ) . ' )';
+ }
+
+ $dbw->delete( 'archive',
+ $toDeleteConds,
+ __METHOD__ );
+ }
+
+ $status = Status::newGood( $restored );
+
+ if ( $failedRevisionCount > 0 ) {
+ $status->warning(
+ wfMessage( 'undeleterevision-duplicate-revid', $failedRevisionCount ) );
+ }
+
+ // Was anything restored at all?
+ if ( $restored ) {
+ $created = (bool)$newid;
+ // Attach the latest revision to the page...
+ $wasnew = $article->updateIfNewerOn( $dbw, $revision );
+ if ( $created || $wasnew ) {
+ // Update site stats, link tables, etc
+ $article->doEditUpdates(
+ $revision,
+ User::newFromName( $revision->getUserText( Revision::RAW ), false ),
+ [
+ 'created' => $created,
+ 'oldcountable' => $oldcountable,
+ 'restored' => true
+ ]
+ );
+ }
+
+ Hooks::run( 'ArticleUndelete', [ &$this->title, $created, $comment, $oldPageId ] );
+ if ( $this->title->getNamespace() == NS_FILE ) {
+ DeferredUpdates::addUpdate( new HTMLCacheUpdate( $this->title, 'imagelinks' ) );
+ }
+ }
+
+ $dbw->endAtomic( __METHOD__ );
+
+ return $status;
+ }
+
+ /**
+ * @return Status
+ */
+ public function getFileStatus() {
+ return $this->fileStatus;
+ }
+
+ /**
+ * @return Status
+ */
+ public function getRevisionStatus() {
+ return $this->revisionStatus;
+ }
+}