Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
55.94% covered (warning)
55.94%
160 / 286
6.67% covered (danger)
6.67%
1 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
UndeletePage
55.94% covered (warning)
55.94%
160 / 286
6.67% covered (danger)
6.67%
1 / 15
450.85
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 setUnsuppress
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setTags
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setUndeleteOnlyTimestamps
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setUndeleteOnlyFileVersions
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 canProbablyUndeleteAssociatedTalk
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 setUndeleteAssociatedTalk
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 undeleteIfAllowed
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 authorizeUndeletion
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 undeleteUnsafe
81.33% covered (warning)
81.33%
61 / 75
0.00% covered (danger)
0.00%
0 / 1
25.15
 runPreUndeleteHook
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 addLogEntry
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 undeleteRevisions
70.87% covered (warning)
70.87%
90 / 127
0.00% covered (danger)
0.00%
0 / 1
33.97
 getFileStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRevisionStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
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.
7 *
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.
12 *
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
17 *
18 * @file
19 */
20
21namespace MediaWiki\Page;
22
23use ChangeTags;
24use HTMLCacheUpdateJob;
25use IDBAccessObject;
26use JobQueueGroup;
27use LocalFile;
28use ManualLogEntry;
29use MediaWiki\Content\IContentHandlerFactory;
30use MediaWiki\Content\ValidationParams;
31use MediaWiki\HookContainer\HookContainer;
32use MediaWiki\HookContainer\HookRunner;
33use MediaWiki\Permissions\Authority;
34use MediaWiki\Permissions\PermissionStatus;
35use MediaWiki\Revision\ArchivedRevisionLookup;
36use MediaWiki\Revision\RevisionRecord;
37use MediaWiki\Revision\RevisionStore;
38use MediaWiki\Status\Status;
39use MediaWiki\Storage\PageUpdaterFactory;
40use MediaWiki\Title\NamespaceInfo;
41use Psr\Log\LoggerInterface;
42use ReadOnlyError;
43use RepoGroup;
44use StatusValue;
45use Wikimedia\Message\ITextFormatter;
46use Wikimedia\Message\MessageValue;
47use Wikimedia\Rdbms\IConnectionProvider;
48use Wikimedia\Rdbms\IDatabase;
49use Wikimedia\Rdbms\ReadOnlyMode;
50use WikiPage;
51
52/**
53 * Backend logic for performing a page undelete action.
54 *
55 * @since 1.38
56 */
57class UndeletePage {
58
59    // Constants used as keys in the StatusValue returned by undelete()
60    public const FILES_RESTORED = 'files';
61    public const REVISIONS_RESTORED = 'revs';
62
63    /** @var HookRunner */
64    private $hookRunner;
65    /** @var JobQueueGroup */
66    private $jobQueueGroup;
67    /** @var IConnectionProvider */
68    private $dbProvider;
69    /** @var LoggerInterface */
70    private $logger;
71    /** @var ReadOnlyMode */
72    private $readOnlyMode;
73    /** @var RepoGroup */
74    private $repoGroup;
75    /** @var RevisionStore */
76    private $revisionStore;
77    /** @var WikiPageFactory */
78    private $wikiPageFactory;
79    /** @var PageUpdaterFactory */
80    private $pageUpdaterFactory;
81    /** @var IContentHandlerFactory */
82    private $contentHandlerFactory;
83    /** @var ArchivedRevisionLookup */
84    private $archivedRevisionLookup;
85    /** @var NamespaceInfo */
86    private $namespaceInfo;
87    /** @var ProperPageIdentity */
88    private $page;
89    /** @var Authority */
90    private $performer;
91    /** @var Status|null */
92    private $fileStatus;
93    /** @var StatusValue|null */
94    private $revisionStatus;
95    /** @var string[] */
96    private $timestamps = [];
97    /** @var int[] */
98    private $fileVersions = [];
99    /** @var bool */
100    private $unsuppress = false;
101    /** @var string[] */
102    private $tags = [];
103    /** @var WikiPage|null If not null, it means that we have to undelete it. */
104    private $associatedTalk;
105    /** @var ITextFormatter */
106    private $contLangMsgTextFormatter;
107
108    /**
109     * @internal Create via the UndeletePageFactory service.
110     * @param HookContainer $hookContainer
111     * @param JobQueueGroup $jobQueueGroup
112     * @param IConnectionProvider $dbProvider
113     * @param ReadOnlyMode $readOnlyMode
114     * @param RepoGroup $repoGroup
115     * @param LoggerInterface $logger
116     * @param RevisionStore $revisionStore
117     * @param WikiPageFactory $wikiPageFactory
118     * @param PageUpdaterFactory $pageUpdaterFactory
119     * @param IContentHandlerFactory $contentHandlerFactory
120     * @param ArchivedRevisionLookup $archivedRevisionLookup
121     * @param NamespaceInfo $namespaceInfo
122     * @param ITextFormatter $contLangMsgTextFormatter
123     * @param ProperPageIdentity $page
124     * @param Authority $performer
125     */
126    public function __construct(
127        HookContainer $hookContainer,
128        JobQueueGroup $jobQueueGroup,
129        IConnectionProvider $dbProvider,
130        ReadOnlyMode $readOnlyMode,
131        RepoGroup $repoGroup,
132        LoggerInterface $logger,
133        RevisionStore $revisionStore,
134        WikiPageFactory $wikiPageFactory,
135        PageUpdaterFactory $pageUpdaterFactory,
136        IContentHandlerFactory $contentHandlerFactory,
137        ArchivedRevisionLookup $archivedRevisionLookup,
138        NamespaceInfo $namespaceInfo,
139        ITextFormatter $contLangMsgTextFormatter,
140        ProperPageIdentity $page,
141        Authority $performer
142    ) {
143        $this->hookRunner = new HookRunner( $hookContainer );
144        $this->jobQueueGroup = $jobQueueGroup;
145        $this->dbProvider = $dbProvider;
146        $this->readOnlyMode = $readOnlyMode;
147        $this->repoGroup = $repoGroup;
148        $this->logger = $logger;
149        $this->revisionStore = $revisionStore;
150        $this->wikiPageFactory = $wikiPageFactory;
151        $this->pageUpdaterFactory = $pageUpdaterFactory;
152        $this->contentHandlerFactory = $contentHandlerFactory;
153        $this->archivedRevisionLookup = $archivedRevisionLookup;
154        $this->namespaceInfo = $namespaceInfo;
155        $this->contLangMsgTextFormatter = $contLangMsgTextFormatter;
156
157        $this->page = $page;
158        $this->performer = $performer;
159    }
160
161    /**
162     * Whether to remove all ar_deleted/fa_deleted restrictions of selected revs.
163     *
164     * @param bool $unsuppress
165     * @return self For chaining
166     */
167    public function setUnsuppress( bool $unsuppress ): self {
168        $this->unsuppress = $unsuppress;
169        return $this;
170    }
171
172    /**
173     * Change tags to add to log entry (the user should be able to add the specified tags before this is called)
174     *
175     * @param string[] $tags
176     * @return self For chaining
177     */
178    public function setTags( array $tags ): self {
179        $this->tags = $tags;
180        return $this;
181    }
182
183    /**
184     * If you don't want to undelete all revisions, pass an array of timestamps to undelete.
185     *
186     * @param string[] $timestamps
187     * @return self For chaining
188     */
189    public function setUndeleteOnlyTimestamps( array $timestamps ): self {
190        $this->timestamps = $timestamps;
191        return $this;
192    }
193
194    /**
195     * If you don't want to undelete all file versions, pass an array of versions to undelete.
196     *
197     * @param int[] $fileVersions
198     * @return self For chaining
199     */
200    public function setUndeleteOnlyFileVersions( array $fileVersions ): self {
201        $this->fileVersions = $fileVersions;
202        return $this;
203    }
204
205    /**
206     * Tests whether it's probably possible to undelete the associated talk page. This checks the replica,
207     * so it may not see the latest master change, and is useful e.g. for building the UI.
208     *
209     * @return StatusValue
210     */
211    public function canProbablyUndeleteAssociatedTalk(): StatusValue {
212        if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) {
213            return StatusValue::newFatal( 'undelete-error-associated-alreadytalk' );
214        }
215        // @todo FIXME: NamespaceInfo should work with PageIdentity
216        $thisWikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
217        $talkPage = $this->wikiPageFactory->newFromLinkTarget(
218            $this->namespaceInfo->getTalkPage( $thisWikiPage->getTitle() )
219        );
220        // NOTE: The talk may exist, but have some deleted revision. That's fine.
221        if ( !$this->archivedRevisionLookup->hasArchivedRevisions( $talkPage ) ) {
222            return StatusValue::newFatal( 'undelete-error-associated-notdeleted' );
223        }
224        return StatusValue::newGood();
225    }
226
227    /**
228     * Whether to delete the associated talk page with the subject page
229     *
230     * @param bool $undelete
231     * @return self For chaining
232     */
233    public function setUndeleteAssociatedTalk( bool $undelete ): self {
234        if ( !$undelete ) {
235            $this->associatedTalk = null;
236            return $this;
237        }
238
239        // @todo FIXME: NamespaceInfo should accept PageIdentity
240        $thisWikiPage = $this->wikiPageFactory->newFromTitle( $this->page );
241        $this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget(
242            $this->namespaceInfo->getTalkPage( $thisWikiPage->getTitle() )
243        );
244        return $this;
245    }
246
247    /**
248     * Same as undeleteUnsafe, but checks permissions.
249     *
250     * @param string $comment
251     * @return StatusValue
252     */
253    public function undeleteIfAllowed( string $comment ): StatusValue {
254        $status = $this->authorizeUndeletion();
255        if ( !$status->isGood() ) {
256            return $status;
257        }
258
259        return $this->undeleteUnsafe( $comment );
260    }
261
262    /**
263     * @return PermissionStatus
264     */
265    private function authorizeUndeletion(): PermissionStatus {
266        $status = PermissionStatus::newEmpty();
267        $this->performer->authorizeWrite( 'undelete', $this->page, $status );
268        if ( $this->associatedTalk ) {
269            $this->performer->authorizeWrite( 'undelete', $this->associatedTalk, $status );
270        }
271        if ( $this->tags ) {
272            $status->merge( ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->performer ) );
273        }
274        return $status;
275    }
276
277    /**
278     * Restore the given (or all) text and file revisions for the page.
279     * Once restored, the items will be removed from the archive tables.
280     * The deletion log will be updated with an undeletion notice.
281     *
282     * This also sets Status objects, $this->fileStatus and $this->revisionStatus
283     * (depending what operations are attempted).
284     *
285     * @note This method doesn't check user permissions. Use undeleteIfAllowed for that.
286     *
287     * @param string $comment
288     * @return StatusValue Good Status with the following value on success:
289     *   [
290     *     self::REVISIONS_RESTORED => number of text revisions restored,
291     *     self::FILES_RESTORED => number of file revisions restored
292     *   ]
293     *   Fatal Status on failure.
294     */
295    public function undeleteUnsafe( string $comment ): StatusValue {
296        $hookStatus = $this->runPreUndeleteHook( $comment );
297        if ( !$hookStatus->isGood() ) {
298            return $hookStatus;
299        }
300        // If both the set of text revisions and file revisions are empty,
301        // restore everything. Otherwise, just restore the requested items.
302        $restoreAll = $this->timestamps === [] && $this->fileVersions === [];
303
304        $restoreText = $restoreAll || $this->timestamps !== [];
305        $restoreFiles = $restoreAll || $this->fileVersions !== [];
306
307        $resStatus = StatusValue::newGood();
308        $filesRestored = 0;
309        if ( $restoreFiles && $this->page->getNamespace() === NS_FILE ) {
310            /** @var LocalFile $img */
311            $img = $this->repoGroup->getLocalRepo()->newFile( $this->page );
312            $img->load( IDBAccessObject::READ_LATEST );
313            $this->fileStatus = $img->restore( $this->fileVersions, $this->unsuppress );
314            if ( !$this->fileStatus->isOK() ) {
315                return $this->fileStatus;
316            }
317            $filesRestored = $this->fileStatus->successCount;
318            $resStatus->merge( $this->fileStatus );
319        }
320
321        $textRestored = 0;
322        $pageCreated = false;
323        $restoredRevision = null;
324        $restoredPageIds = [];
325        if ( $restoreText ) {
326            $this->revisionStatus = $this->undeleteRevisions( $this->page, $this->timestamps, $comment );
327            if ( !$this->revisionStatus->isOK() ) {
328                return $this->revisionStatus;
329            }
330
331            [ $textRestored, $pageCreated, $restoredRevision, $restoredPageIds ] = $this->revisionStatus->getValue();
332            $resStatus->merge( $this->revisionStatus );
333        }
334
335        $talkRestored = 0;
336        $talkCreated = false;
337        $restoredTalkRevision = null;
338        $restoredTalkPageIds = [];
339        if ( $this->associatedTalk ) {
340            $talkStatus = $this->canProbablyUndeleteAssociatedTalk();
341            // if undeletion of the page fails we don't want to undelete the talk page
342            if ( $talkStatus->isGood() && $resStatus->isGood() ) {
343                $talkStatus = $this->undeleteRevisions( $this->associatedTalk, [], $comment );
344                if ( !$talkStatus->isOK() ) {
345                    return $talkStatus;
346                }
347                [ $talkRestored, $talkCreated, $restoredTalkRevision, $restoredTalkPageIds ] = $talkStatus->getValue();
348
349            } else {
350                // Add errors as warnings since the talk page is secondary to the main action
351                foreach ( $talkStatus->getErrors() as $error ) {
352                    $resStatus->warning( $error['message'], $error['params'] );
353                }
354            }
355        }
356
357        $resStatus->value = [
358            self::REVISIONS_RESTORED => $textRestored + $talkRestored,
359            self::FILES_RESTORED => $filesRestored
360        ];
361
362        if ( !$textRestored && !$filesRestored && !$talkRestored ) {
363            $this->logger->debug( "Undelete: nothing undeleted..." );
364            return $resStatus;
365        }
366
367        if ( $textRestored || $filesRestored ) {
368            $logEntry = $this->addLogEntry( $this->page, $comment, $textRestored, $filesRestored );
369
370            if ( $textRestored ) {
371                $this->hookRunner->onPageUndeleteComplete(
372                    $this->page,
373                    $this->performer,
374                    $comment,
375                    $restoredRevision,
376                    $logEntry,
377                    $textRestored,
378                    $pageCreated,
379                    $restoredPageIds
380                );
381            }
382        }
383
384        if ( $talkRestored ) {
385            $talkRestoredComment = $this->contLangMsgTextFormatter->format(
386                MessageValue::new( 'undelete-talk-summary-prefix' )->plaintextParams( $comment )
387            );
388            $logEntry = $this->addLogEntry( $this->associatedTalk, $talkRestoredComment, $talkRestored, 0 );
389
390            $this->hookRunner->onPageUndeleteComplete(
391                $this->associatedTalk,
392                $this->performer,
393                $talkRestoredComment,
394                $restoredTalkRevision,
395                $logEntry,
396                $talkRestored,
397                $talkCreated,
398                $restoredTalkPageIds
399            );
400        }
401
402        return $resStatus;
403    }
404
405    /**
406     * @param string $comment
407     * @return StatusValue
408     */
409    private function runPreUndeleteHook( string $comment ): StatusValue {
410        $checkPages = [ $this->page ];
411        if ( $this->associatedTalk ) {
412            $checkPages[] = $this->associatedTalk;
413        }
414        foreach ( $checkPages as $page ) {
415            $hookStatus = StatusValue::newGood();
416            $hookRes = $this->hookRunner->onPageUndelete(
417                $page,
418                $this->performer,
419                $comment,
420                $this->unsuppress,
421                $this->timestamps,
422                $this->fileVersions,
423                $hookStatus
424            );
425            if ( !$hookRes && !$hookStatus->isGood() ) {
426                // Note: as per the PageUndeleteHook documentation, `return false` is ignored if $status is good.
427                return $hookStatus;
428            }
429        }
430        return Status::newGood();
431    }
432
433    /**
434     * @param ProperPageIdentity $page
435     * @param string $comment
436     * @param int $textRestored
437     * @param int $filesRestored
438     *
439     * @return ManualLogEntry
440     */
441    private function addLogEntry(
442        ProperPageIdentity $page,
443        string $comment,
444        int $textRestored,
445        int $filesRestored
446    ): ManualLogEntry {
447        $logEntry = new ManualLogEntry( 'delete', 'restore' );
448        $logEntry->setPerformer( $this->performer->getUser() );
449        $logEntry->setTarget( $page );
450        $logEntry->setComment( $comment );
451        $logEntry->addTags( $this->tags );
452        $logEntry->setParameters( [
453            ':assoc:count' => [
454                'revisions' => $textRestored,
455                'files' => $filesRestored,
456            ],
457        ] );
458
459        $logid = $logEntry->insert();
460        $logEntry->publish( $logid );
461
462        return $logEntry;
463    }
464
465    /**
466     * This is the meaty bit -- It restores archived revisions of the given page
467     * to the revision table.
468     *
469     * @param ProperPageIdentity $page
470     * @param string[] $timestamps
471     * @param string $comment
472     * @throws ReadOnlyError
473     * @return StatusValue Status object containing the number of revisions restored on success
474     */
475    private function undeleteRevisions( ProperPageIdentity $page, array $timestamps, string $comment ): StatusValue {
476        if ( $this->readOnlyMode->isReadOnly() ) {
477            throw new ReadOnlyError();
478        }
479
480        $dbw = $this->dbProvider->getPrimaryDatabase();
481        $dbw->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE );
482
483        $oldWhere = [
484            'ar_namespace' => $page->getNamespace(),
485            'ar_title' => $page->getDBkey(),
486        ];
487        if ( $timestamps ) {
488            $oldWhere['ar_timestamp'] = array_map( [ $dbw, 'timestamp' ], $timestamps );
489        }
490
491        $revisionStore = $this->revisionStore;
492        $result = $revisionStore->newArchiveSelectQueryBuilder( $dbw )
493            ->joinComment()
494            ->leftJoin( 'revision', null, 'ar_rev_id=rev_id' )
495            ->field( 'rev_id' )
496            ->where( $oldWhere )
497            ->orderBy( 'ar_timestamp' )
498            ->caller( __METHOD__ )->fetchResultSet();
499
500        $rev_count = $result->numRows();
501        if ( !$rev_count ) {
502            $this->logger->debug( __METHOD__ . ": no revisions to restore" );
503
504            // Status value is count of revisions, whether the page has been created,
505            // last revision undeleted and all undeleted pages
506            $status = Status::newGood( [ 0, false, null, [] ] );
507            $status->error( "undelete-no-results" );
508            $dbw->endAtomic( __METHOD__ );
509
510            return $status;
511        }
512
513        $result->seek( $rev_count - 1 );
514        $latestRestorableRow = $result->current();
515
516        // move back
517        $result->seek( 0 );
518
519        $wikiPage = $this->wikiPageFactory->newFromTitle( $page );
520
521        $created = true;
522        $oldcountable = false;
523        $updatedCurrentRevision = false;
524        $restoredRevCount = 0;
525        $restoredPages = [];
526
527        // pass this to ArticleUndelete hook
528        $oldPageId = (int)$latestRestorableRow->ar_page_id;
529
530        // Grab the content to check consistency with global state before restoring the page.
531        // XXX: The only current use case is Wikibase, which tries to enforce uniqueness of
532        // certain things across all pages. There may be a better way to do that.
533        $revision = $revisionStore->newRevisionFromArchiveRow(
534            $latestRestorableRow,
535            0,
536            $page
537        );
538
539        foreach ( $revision->getSlotRoles() as $role ) {
540            $content = $revision->getContent( $role, RevisionRecord::RAW );
541            // NOTE: article ID may not be known yet. validateSave() should not modify the database.
542            $contentHandler = $this->contentHandlerFactory->getContentHandler( $content->getModel() );
543            $validationParams = new ValidationParams( $wikiPage, 0 );
544            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable RAW never returns null
545            $status = $contentHandler->validateSave( $content, $validationParams );
546            if ( !$status->isOK() ) {
547                $dbw->endAtomic( __METHOD__ );
548
549                return $status;
550            }
551        }
552
553        $pageId = $wikiPage->insertOn( $dbw, $latestRestorableRow->ar_page_id );
554        if ( $pageId === false ) {
555            // The page ID is reserved; let's pick another
556            $pageId = $wikiPage->insertOn( $dbw );
557            if ( $pageId === false ) {
558                // The page title must be already taken (race condition)
559                $created = false;
560            }
561        }
562
563        # Does this page already exist? We'll have to update it...
564        if ( !$created ) {
565            # Load latest data for the current page (T33179)
566            $wikiPage->loadPageData( IDBAccessObject::READ_EXCLUSIVE );
567            $pageId = $wikiPage->getId();
568            $oldcountable = $wikiPage->isCountable();
569
570            $previousTimestamp = false;
571            $latestRevId = $wikiPage->getLatest();
572            if ( $latestRevId ) {
573                $previousTimestamp = $revisionStore->getTimestampFromId(
574                    $latestRevId,
575                    IDBAccessObject::READ_LATEST
576                );
577            }
578            if ( $previousTimestamp === false ) {
579                $this->logger->debug( __METHOD__ . ": existing page refers to a page_latest that does not exist" );
580
581                // Status value is count of revisions, whether the page has been created,
582                // last revision undeleted and all undeleted pages
583                $status = Status::newGood( [ 0, false, null, [] ] );
584                $status->error( 'undeleterevision-missing' );
585                $dbw->cancelAtomic( __METHOD__ );
586
587                return $status;
588            }
589        } else {
590            $previousTimestamp = 0;
591        }
592
593        // Check if a deleted revision will become the current revision...
594        if ( $latestRestorableRow->ar_timestamp > $previousTimestamp ) {
595            // Check the state of the newest to-be version...
596            if ( !$this->unsuppress
597                && ( $latestRestorableRow->ar_deleted & RevisionRecord::DELETED_TEXT )
598            ) {
599                $dbw->cancelAtomic( __METHOD__ );
600
601                return Status::newFatal( "undeleterevdel" );
602            }
603            $updatedCurrentRevision = true;
604        }
605
606        foreach ( $result as $row ) {
607            // Insert one revision at a time...maintaining deletion status
608            // unless we are specifically removing all restrictions...
609            $revision = $revisionStore->newRevisionFromArchiveRow(
610                $row,
611                0,
612                $page,
613                [
614                    'page_id' => $pageId,
615                    'deleted' => $this->unsuppress ? 0 : $row->ar_deleted
616                ]
617            );
618
619            // This will also copy the revision to ip_changes if it was an IP edit.
620            $revision = $revisionStore->insertRevisionOn( $revision, $dbw );
621
622            $restoredRevCount++;
623
624            $this->hookRunner->onRevisionUndeleted( $revision, $row->ar_page_id );
625
626            $restoredPages[$row->ar_page_id] = true;
627        }
628
629        // Now that it's safely stored, take it out of the archive
630        $dbw->newDeleteQueryBuilder()
631            ->deleteFrom( 'archive' )
632            ->where( $oldWhere )
633            ->caller( __METHOD__ )->execute();
634
635        // Status value is count of revisions, whether the page has been created,
636        // last revision undeleted and all undeleted pages
637        $status = Status::newGood( [ $restoredRevCount, $created, $revision, $restoredPages ] );
638
639        // Was anything restored at all?
640        if ( $restoredRevCount ) {
641
642            if ( $updatedCurrentRevision ) {
643                // Attach the latest revision to the page...
644                // XXX: updateRevisionOn should probably move into a PageStore service.
645                $wasnew = $wikiPage->updateRevisionOn(
646                    $dbw,
647                    $revision,
648                    $created ? 0 : $wikiPage->getLatest()
649                );
650            } else {
651                $wasnew = false;
652            }
653
654            if ( $created || $wasnew ) {
655                // Update site stats, link tables, etc
656                $user = $revision->getUser( RevisionRecord::RAW );
657                $options = [
658                    'created' => $created,
659                    'oldcountable' => $oldcountable,
660                    'restored' => true,
661                    'causeAction' => 'undelete-page',
662                    'causeAgent' => $user->getName(),
663                ];
664
665                $updater = $this->pageUpdaterFactory->newDerivedPageDataUpdater( $wikiPage );
666                $updater->prepareUpdate( $revision, $options );
667                $updater->doUpdates();
668            }
669
670            $this->hookRunner->onArticleUndelete(
671                $wikiPage->getTitle(), $created, $comment, $oldPageId, $restoredPages );
672
673            if ( $page->getNamespace() === NS_FILE ) {
674                $job = HTMLCacheUpdateJob::newForBacklinks(
675                    $page,
676                    'imagelinks',
677                    [ 'causeAction' => 'undelete-file' ]
678                );
679                $this->jobQueueGroup->lazyPush( $job );
680            }
681        }
682
683        $dbw->endAtomic( __METHOD__ );
684
685        return $status;
686    }
687
688    /**
689     * @internal BC method to be used by PageArchive only
690     * @return Status|null
691     */
692    public function getFileStatus(): ?Status {
693        return $this->fileStatus;
694    }
695
696    /**
697     * @internal BC methods to be used by PageArchive only
698     * @return StatusValue|null
699     */
700    public function getRevisionStatus(): ?StatusValue {
701        return $this->revisionStatus;
702    }
703}