Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
57.67% |
173 / 300 |
|
6.67% |
1 / 15 |
CRAP | |
0.00% |
0 / 1 |
UndeletePage | |
57.67% |
173 / 300 |
|
6.67% |
1 / 15 |
418.80 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
2 | |||
setUnsuppress | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
setTags | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
setUndeleteOnlyTimestamps | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
setUndeleteOnlyFileVersions | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
canProbablyUndeleteAssociatedTalk | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
setUndeleteAssociatedTalk | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
undeleteIfAllowed | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
authorizeUndeletion | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
undeleteUnsafe | |
82.28% |
65 / 79 |
|
0.00% |
0 / 1 |
24.69 | |||
runPreUndeleteHook | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
30 | |||
addLogEntry | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
2 | |||
undeleteRevisions | |
72.26% |
99 / 137 |
|
0.00% |
0 / 1 |
34.29 | |||
getFileStatus | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getRevisionStatus | |
0.00% |
0 / 1 |
|
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 | |
21 | namespace MediaWiki\Page; |
22 | |
23 | use MediaWiki\ChangeTags\ChangeTags; |
24 | use MediaWiki\Content\IContentHandlerFactory; |
25 | use MediaWiki\Content\ValidationParams; |
26 | use MediaWiki\Exception\ReadOnlyError; |
27 | use MediaWiki\FileRepo\File\LocalFile; |
28 | use MediaWiki\FileRepo\RepoGroup; |
29 | use MediaWiki\HookContainer\HookContainer; |
30 | use MediaWiki\HookContainer\HookRunner; |
31 | use MediaWiki\JobQueue\JobQueueGroup; |
32 | use MediaWiki\JobQueue\Jobs\HTMLCacheUpdateJob; |
33 | use MediaWiki\Logging\ManualLogEntry; |
34 | use MediaWiki\Page\Event\PageLatestRevisionChangedEvent; |
35 | use MediaWiki\Permissions\Authority; |
36 | use MediaWiki\Permissions\PermissionStatus; |
37 | use MediaWiki\Revision\ArchivedRevisionLookup; |
38 | use MediaWiki\Revision\RevisionRecord; |
39 | use MediaWiki\Revision\RevisionStore; |
40 | use MediaWiki\Status\Status; |
41 | use MediaWiki\Storage\PageUpdater; |
42 | use MediaWiki\Storage\PageUpdaterFactory; |
43 | use MediaWiki\Title\NamespaceInfo; |
44 | use Psr\Log\LoggerInterface; |
45 | use StatusValue; |
46 | use Wikimedia\Assert\Assert; |
47 | use Wikimedia\Message\ITextFormatter; |
48 | use Wikimedia\Message\MessageValue; |
49 | use Wikimedia\Rdbms\IConnectionProvider; |
50 | use Wikimedia\Rdbms\IDatabase; |
51 | use Wikimedia\Rdbms\IDBAccessObject; |
52 | use Wikimedia\Rdbms\ReadOnlyMode; |
53 | |
54 | /** |
55 | * Backend logic for performing a page undelete action. |
56 | * |
57 | * @since 1.38 |
58 | */ |
59 | class 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 | public function canProbablyUndeleteAssociatedTalk(): StatusValue { |
183 | if ( $this->namespaceInfo->isTalk( $this->page->getNamespace() ) ) { |
184 | return StatusValue::newFatal( 'undelete-error-associated-alreadytalk' ); |
185 | } |
186 | // @todo FIXME: NamespaceInfo should work with PageIdentity |
187 | $thisWikiPage = $this->wikiPageFactory->newFromTitle( $this->page ); |
188 | $talkPage = $this->wikiPageFactory->newFromLinkTarget( |
189 | $this->namespaceInfo->getTalkPage( $thisWikiPage->getTitle() ) |
190 | ); |
191 | // NOTE: The talk may exist, but have some deleted revision. That's fine. |
192 | if ( !$this->archivedRevisionLookup->hasArchivedRevisions( $talkPage ) ) { |
193 | return StatusValue::newFatal( 'undelete-error-associated-notdeleted' ); |
194 | } |
195 | return StatusValue::newGood(); |
196 | } |
197 | |
198 | /** |
199 | * Whether to delete the associated talk page with the subject page |
200 | * |
201 | * @param bool $undelete |
202 | * @return self For chaining |
203 | */ |
204 | public function setUndeleteAssociatedTalk( bool $undelete ): self { |
205 | if ( !$undelete ) { |
206 | $this->associatedTalk = null; |
207 | return $this; |
208 | } |
209 | |
210 | // @todo FIXME: NamespaceInfo should accept PageIdentity |
211 | $thisWikiPage = $this->wikiPageFactory->newFromTitle( $this->page ); |
212 | $this->associatedTalk = $this->wikiPageFactory->newFromLinkTarget( |
213 | $this->namespaceInfo->getTalkPage( $thisWikiPage->getTitle() ) |
214 | ); |
215 | return $this; |
216 | } |
217 | |
218 | /** |
219 | * Same as undeleteUnsafe, but checks permissions. |
220 | * |
221 | * @param string $comment |
222 | * @return StatusValue |
223 | */ |
224 | public function undeleteIfAllowed( string $comment ): StatusValue { |
225 | $status = $this->authorizeUndeletion(); |
226 | if ( !$status->isGood() ) { |
227 | return $status; |
228 | } |
229 | |
230 | return $this->undeleteUnsafe( $comment ); |
231 | } |
232 | |
233 | private function authorizeUndeletion(): PermissionStatus { |
234 | $status = PermissionStatus::newEmpty(); |
235 | $this->performer->authorizeWrite( 'undelete', $this->page, $status ); |
236 | if ( $this->associatedTalk ) { |
237 | $this->performer->authorizeWrite( 'undelete', $this->associatedTalk, $status ); |
238 | } |
239 | if ( $this->tags ) { |
240 | $status->merge( ChangeTags::canAddTagsAccompanyingChange( $this->tags, $this->performer ) ); |
241 | } |
242 | return $status; |
243 | } |
244 | |
245 | /** |
246 | * Restore the given (or all) text and file revisions for the page. |
247 | * Once restored, the items will be removed from the archive tables. |
248 | * The deletion log will be updated with an undeletion notice. |
249 | * |
250 | * This also sets Status objects, $this->fileStatus and $this->revisionStatus |
251 | * (depending what operations are attempted). |
252 | * |
253 | * @note This method doesn't check user permissions. Use undeleteIfAllowed for that. |
254 | * |
255 | * @param string $comment |
256 | * @return StatusValue Good Status with the following value on success: |
257 | * [ |
258 | * self::REVISIONS_RESTORED => number of text revisions restored, |
259 | * self::FILES_RESTORED => number of file revisions restored |
260 | * ] |
261 | * Fatal Status on failure. |
262 | */ |
263 | public function undeleteUnsafe( string $comment ): StatusValue { |
264 | $hookStatus = $this->runPreUndeleteHook( $comment ); |
265 | if ( !$hookStatus->isGood() ) { |
266 | return $hookStatus; |
267 | } |
268 | // If both the set of text revisions and file revisions are empty, |
269 | // restore everything. Otherwise, just restore the requested items. |
270 | $restoreAll = $this->timestamps === [] && $this->fileVersions === []; |
271 | |
272 | $restoreText = $restoreAll || $this->timestamps !== []; |
273 | $restoreFiles = $restoreAll || $this->fileVersions !== []; |
274 | |
275 | $resStatus = StatusValue::newGood(); |
276 | $filesRestored = 0; |
277 | if ( $restoreFiles && $this->page->getNamespace() === NS_FILE ) { |
278 | /** @var LocalFile $img */ |
279 | $img = $this->repoGroup->getLocalRepo()->newFile( $this->page ); |
280 | $img->load( IDBAccessObject::READ_LATEST ); |
281 | $this->fileStatus = $img->restore( $this->fileVersions, $this->unsuppress ); |
282 | if ( !$this->fileStatus->isOK() ) { |
283 | return $this->fileStatus; |
284 | } |
285 | $filesRestored = $this->fileStatus->successCount; |
286 | $resStatus->merge( $this->fileStatus ); |
287 | } |
288 | |
289 | $textRestored = 0; |
290 | $pageCreated = false; |
291 | $restoredRevision = null; |
292 | $restoredPageIds = []; |
293 | if ( $restoreText ) { |
294 | // If we already restored files, then don't bail if there isn't any text to restore |
295 | $acceptNoRevisions = $filesRestored > 0; |
296 | $this->revisionStatus = $this->undeleteRevisions( |
297 | $this->page, $this->timestamps, |
298 | $comment, $acceptNoRevisions |
299 | ); |
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, false ); |
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 | private function runPreUndeleteHook( string $comment ): StatusValue { |
379 | $checkPages = [ $this->page ]; |
380 | if ( $this->associatedTalk ) { |
381 | $checkPages[] = $this->associatedTalk; |
382 | } |
383 | foreach ( $checkPages as $page ) { |
384 | $hookStatus = StatusValue::newGood(); |
385 | $hookRes = $this->hookRunner->onPageUndelete( |
386 | $page, |
387 | $this->performer, |
388 | $comment, |
389 | $this->unsuppress, |
390 | $this->timestamps, |
391 | $this->fileVersions, |
392 | $hookStatus |
393 | ); |
394 | if ( !$hookRes && !$hookStatus->isGood() ) { |
395 | // Note: as per the PageUndeleteHook documentation, `return false` is ignored if $status is good. |
396 | return $hookStatus; |
397 | } |
398 | } |
399 | return Status::newGood(); |
400 | } |
401 | |
402 | /** |
403 | * @param ProperPageIdentity $page |
404 | * @param string $comment |
405 | * @param int $textRestored |
406 | * @param int $filesRestored |
407 | * |
408 | * @return ManualLogEntry |
409 | */ |
410 | private function addLogEntry( |
411 | ProperPageIdentity $page, |
412 | string $comment, |
413 | int $textRestored, |
414 | int $filesRestored |
415 | ): ManualLogEntry { |
416 | $logEntry = new ManualLogEntry( 'delete', 'restore' ); |
417 | $logEntry->setPerformer( $this->performer->getUser() ); |
418 | $logEntry->setTarget( $page ); |
419 | $logEntry->setComment( $comment ); |
420 | $logEntry->addTags( $this->tags ); |
421 | $logEntry->setParameters( [ |
422 | ':assoc:count' => [ |
423 | 'revisions' => $textRestored, |
424 | 'files' => $filesRestored, |
425 | ], |
426 | ] ); |
427 | |
428 | $logid = $logEntry->insert(); |
429 | $logEntry->publish( $logid ); |
430 | |
431 | return $logEntry; |
432 | } |
433 | |
434 | /** |
435 | * This is the meaty bit -- It restores archived revisions of the given page |
436 | * to the revision table. |
437 | * |
438 | * @param ProperPageIdentity $page |
439 | * @param string[] $timestamps |
440 | * @param string $comment |
441 | * @param bool $acceptNoRevisions Whether to return a good status rather than an error |
442 | * if no revisions are undeleted. |
443 | * @throws ReadOnlyError |
444 | * @return StatusValue Status object containing the number of revisions restored on success |
445 | */ |
446 | private function undeleteRevisions( |
447 | ProperPageIdentity $page, array $timestamps, |
448 | string $comment, bool $acceptNoRevisions |
449 | ): StatusValue { |
450 | if ( $this->readOnlyMode->isReadOnly() ) { |
451 | throw new ReadOnlyError(); |
452 | } |
453 | |
454 | $dbw = $this->dbProvider->getPrimaryDatabase(); |
455 | $dbw->startAtomic( __METHOD__, IDatabase::ATOMIC_CANCELABLE ); |
456 | |
457 | $oldWhere = [ |
458 | 'ar_namespace' => $page->getNamespace(), |
459 | 'ar_title' => $page->getDBkey(), |
460 | ]; |
461 | if ( $timestamps ) { |
462 | $oldWhere['ar_timestamp'] = array_map( [ $dbw, 'timestamp' ], $timestamps ); |
463 | } |
464 | |
465 | $revisionStore = $this->revisionStore; |
466 | $result = $revisionStore->newArchiveSelectQueryBuilder( $dbw ) |
467 | ->joinComment() |
468 | ->leftJoin( 'revision', null, 'ar_rev_id=rev_id' ) |
469 | ->field( 'rev_id' ) |
470 | ->where( $oldWhere ) |
471 | ->orderBy( 'ar_timestamp' ) |
472 | ->caller( __METHOD__ )->fetchResultSet(); |
473 | |
474 | $rev_count = $result->numRows(); |
475 | if ( !$rev_count ) { |
476 | $this->logger->debug( __METHOD__ . ": no revisions to restore" ); |
477 | |
478 | // Status value is count of revisions, whether the page has been created, |
479 | // last revision undeleted and all undeleted pages |
480 | $status = Status::newGood( [ 0, false, null, [] ] ); |
481 | if ( !$acceptNoRevisions ) { |
482 | $status->error( "undelete-no-results" ); |
483 | } |
484 | $dbw->endAtomic( __METHOD__ ); |
485 | |
486 | return $status; |
487 | } |
488 | |
489 | $result->seek( $rev_count - 1 ); |
490 | $latestRestorableRow = $result->current(); |
491 | |
492 | // move back |
493 | $result->seek( 0 ); |
494 | |
495 | $wikiPage = $this->wikiPageFactory->newFromTitle( $page ); |
496 | |
497 | $created = true; |
498 | $oldcountable = false; |
499 | $updatedCurrentRevision = false; |
500 | $restoredRevCount = 0; |
501 | $restoredPages = []; |
502 | |
503 | // pass this to ArticleUndelete hook |
504 | $oldPageId = (int)$latestRestorableRow->ar_page_id; |
505 | |
506 | // Grab the content to check consistency with global state before restoring the page. |
507 | // XXX: The only current use case is Wikibase, which tries to enforce uniqueness of |
508 | // certain things across all pages. There may be a better way to do that. |
509 | $revision = $revisionStore->newRevisionFromArchiveRow( |
510 | $latestRestorableRow, |
511 | 0, |
512 | $page |
513 | ); |
514 | |
515 | foreach ( $revision->getSlotRoles() as $role ) { |
516 | $content = $revision->getContent( $role, RevisionRecord::RAW ); |
517 | // NOTE: article ID may not be known yet. validateSave() should not modify the database. |
518 | $contentHandler = $this->contentHandlerFactory->getContentHandler( $content->getModel() ); |
519 | $validationParams = new ValidationParams( $wikiPage, 0 ); |
520 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable RAW never returns null |
521 | $status = $contentHandler->validateSave( $content, $validationParams ); |
522 | if ( !$status->isOK() ) { |
523 | $dbw->endAtomic( __METHOD__ ); |
524 | |
525 | return $status; |
526 | } |
527 | } |
528 | |
529 | // Grab page state before changing it |
530 | $updater = $this->pageUpdaterFactory->newDerivedPageDataUpdater( $wikiPage ); |
531 | $updater->grabCurrentRevision(); |
532 | |
533 | $pageId = $wikiPage->insertOn( $dbw, $latestRestorableRow->ar_page_id ); |
534 | if ( $pageId === false ) { |
535 | // The page ID is reserved; let's pick another |
536 | $pageId = $wikiPage->insertOn( $dbw ); |
537 | if ( $pageId === false ) { |
538 | // The page title must be already taken (race condition) |
539 | $created = false; |
540 | } |
541 | } |
542 | |
543 | # Does this page already exist? We'll have to update it... |
544 | if ( !$created ) { |
545 | # Load latest data for the current page (T33179) |
546 | $wikiPage->loadPageData( IDBAccessObject::READ_EXCLUSIVE ); |
547 | $pageId = $wikiPage->getId(); |
548 | $oldcountable = $wikiPage->isCountable(); |
549 | |
550 | $previousTimestamp = false; |
551 | $latestRevId = $wikiPage->getLatest(); |
552 | if ( $latestRevId ) { |
553 | $previousTimestamp = $revisionStore->getTimestampFromId( |
554 | $latestRevId, |
555 | IDBAccessObject::READ_LATEST |
556 | ); |
557 | } |
558 | if ( $previousTimestamp === false ) { |
559 | $this->logger->debug( __METHOD__ . ": existing page refers to a page_latest that does not exist" ); |
560 | |
561 | // Status value is count of revisions, whether the page has been created, |
562 | // last revision undeleted and all undeleted pages |
563 | $status = Status::newGood( [ 0, false, null, [] ] ); |
564 | $status->error( 'undeleterevision-missing' ); |
565 | $dbw->cancelAtomic( __METHOD__ ); |
566 | |
567 | return $status; |
568 | } |
569 | } else { |
570 | $previousTimestamp = 0; |
571 | } |
572 | |
573 | // Re-create the PageIdentity using $pageId |
574 | $page = PageIdentityValue::localIdentity( |
575 | $pageId, |
576 | $page->getNamespace(), |
577 | $page->getDBkey() |
578 | ); |
579 | |
580 | Assert::postcondition( $page->exists(), 'The page should exist now' ); |
581 | |
582 | // Check if a deleted revision will become the current revision... |
583 | $latestRestorableRowTimestamp = wfTimestamp( TS_MW, $latestRestorableRow->ar_timestamp ); |
584 | if ( $latestRestorableRowTimestamp > $previousTimestamp ) { |
585 | // Check the state of the newest to-be version... |
586 | if ( !$this->unsuppress |
587 | && ( $latestRestorableRow->ar_deleted & RevisionRecord::DELETED_TEXT ) |
588 | ) { |
589 | $dbw->cancelAtomic( __METHOD__ ); |
590 | |
591 | return Status::newFatal( "undeleterevdel" ); |
592 | } |
593 | $updatedCurrentRevision = true; |
594 | } |
595 | |
596 | foreach ( $result as $row ) { |
597 | // Insert one revision at a time...maintaining deletion status |
598 | // unless we are specifically removing all restrictions... |
599 | $revision = $revisionStore->newRevisionFromArchiveRow( |
600 | $row, |
601 | 0, |
602 | $page, |
603 | [ |
604 | 'page_id' => $pageId, |
605 | 'deleted' => $this->unsuppress ? 0 : $row->ar_deleted |
606 | ] |
607 | ); |
608 | |
609 | // This will also copy the revision to ip_changes if it was an IP edit. |
610 | $revision = $revisionStore->insertRevisionOn( $revision, $dbw ); |
611 | |
612 | $restoredRevCount++; |
613 | |
614 | $this->hookRunner->onRevisionUndeleted( $revision, $row->ar_page_id ); |
615 | |
616 | $restoredPages[$row->ar_page_id] = true; |
617 | } |
618 | |
619 | // Now that it's safely stored, take it out of the archive |
620 | $dbw->newDeleteQueryBuilder() |
621 | ->deleteFrom( 'archive' ) |
622 | ->where( $oldWhere ) |
623 | ->caller( __METHOD__ )->execute(); |
624 | |
625 | // Status value is count of revisions, whether the page has been created, |
626 | // last revision undeleted and all undeleted pages |
627 | $status = Status::newGood( [ $restoredRevCount, $created, $revision, $restoredPages ] ); |
628 | |
629 | // Was anything restored at all? |
630 | if ( $restoredRevCount ) { |
631 | |
632 | if ( $updatedCurrentRevision ) { |
633 | // Attach the latest revision to the page... |
634 | // XXX: updateRevisionOn should probably move into a PageStore service. |
635 | $wasnew = $wikiPage->updateRevisionOn( |
636 | $dbw, |
637 | $revision, |
638 | $created ? 0 : $wikiPage->getLatest() |
639 | ); |
640 | } else { |
641 | $wasnew = false; |
642 | } |
643 | |
644 | if ( $created || $wasnew ) { |
645 | // Update site stats, link tables, etc |
646 | $options = [ |
647 | PageLatestRevisionChangedEvent::FLAG_SILENT => true, |
648 | PageLatestRevisionChangedEvent::FLAG_IMPLICIT => true, |
649 | 'created' => $created, |
650 | 'oldcountable' => $oldcountable, |
651 | 'reason' => $comment |
652 | ]; |
653 | |
654 | $updater->setCause( PageUpdater::CAUSE_UNDELETE ); |
655 | $updater->setPerformer( $this->performer->getUser() ); |
656 | $updater->prepareUpdate( $revision, $options ); |
657 | $updater->doUpdates(); |
658 | } |
659 | |
660 | $this->hookRunner->onArticleUndelete( |
661 | $wikiPage->getTitle(), $created, $comment, $oldPageId, $restoredPages ); |
662 | |
663 | if ( $page->getNamespace() === NS_FILE ) { |
664 | $job = HTMLCacheUpdateJob::newForBacklinks( |
665 | $page, |
666 | 'imagelinks', |
667 | [ 'causeAction' => 'undelete-file' ] |
668 | ); |
669 | $this->jobQueueGroup->lazyPush( $job ); |
670 | } |
671 | } |
672 | |
673 | $dbw->endAtomic( __METHOD__ ); |
674 | |
675 | return $status; |
676 | } |
677 | |
678 | /** |
679 | * @internal BC method to be used by PageArchive only |
680 | * @return Status|null |
681 | */ |
682 | public function getFileStatus(): ?Status { |
683 | return $this->fileStatus; |
684 | } |
685 | |
686 | /** |
687 | * @internal BC methods to be used by PageArchive only |
688 | * @return StatusValue|null |
689 | */ |
690 | public function getRevisionStatus(): ?StatusValue { |
691 | return $this->revisionStatus; |
692 | } |
693 | } |