Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
57.14% |
168 / 294 |
|
6.67% |
1 / 15 |
CRAP | |
0.00% |
0 / 1 |
UndeletePage | |
57.14% |
168 / 294 |
|
6.67% |
1 / 15 |
420.36 | |
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 | |
81.33% |
61 / 75 |
|
0.00% |
0 / 1 |
25.15 | |||
runPreUndeleteHook | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
30 | |||
addLogEntry | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
2 | |||
undeleteRevisions | |
72.59% |
98 / 135 |
|
0.00% |
0 / 1 |
31.96 | |||
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 ChangeTags; |
24 | use HTMLCacheUpdateJob; |
25 | use JobQueueGroup; |
26 | use LocalFile; |
27 | use ManualLogEntry; |
28 | use MediaWiki\Content\IContentHandlerFactory; |
29 | use MediaWiki\Content\ValidationParams; |
30 | use MediaWiki\HookContainer\HookContainer; |
31 | use MediaWiki\HookContainer\HookRunner; |
32 | use MediaWiki\Permissions\Authority; |
33 | use MediaWiki\Permissions\PermissionStatus; |
34 | use MediaWiki\Revision\ArchivedRevisionLookup; |
35 | use MediaWiki\Revision\RevisionRecord; |
36 | use MediaWiki\Revision\RevisionStore; |
37 | use MediaWiki\Status\Status; |
38 | use MediaWiki\Storage\PageUpdatedEvent; |
39 | use MediaWiki\Storage\PageUpdaterFactory; |
40 | use MediaWiki\Title\NamespaceInfo; |
41 | use Psr\Log\LoggerInterface; |
42 | use ReadOnlyError; |
43 | use RepoGroup; |
44 | use StatusValue; |
45 | use Wikimedia\Assert\Assert; |
46 | use Wikimedia\Message\ITextFormatter; |
47 | use Wikimedia\Message\MessageValue; |
48 | use Wikimedia\Rdbms\IConnectionProvider; |
49 | use Wikimedia\Rdbms\IDatabase; |
50 | use Wikimedia\Rdbms\IDBAccessObject; |
51 | use Wikimedia\Rdbms\ReadOnlyMode; |
52 | use WikiPage; |
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 | * @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 | } |