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