Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
55.94% |
160 / 286 |
|
6.67% |
1 / 15 |
CRAP | |
0.00% |
0 / 1 |
UndeletePage | |
55.94% |
160 / 286 |
|
6.67% |
1 / 15 |
450.85 | |
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 | |
70.87% |
90 / 127 |
|
0.00% |
0 / 1 |
33.97 | |||
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 IDBAccessObject; |
26 | use JobQueueGroup; |
27 | use LocalFile; |
28 | use ManualLogEntry; |
29 | use MediaWiki\Content\IContentHandlerFactory; |
30 | use MediaWiki\Content\ValidationParams; |
31 | use MediaWiki\HookContainer\HookContainer; |
32 | use MediaWiki\HookContainer\HookRunner; |
33 | use MediaWiki\Permissions\Authority; |
34 | use MediaWiki\Permissions\PermissionStatus; |
35 | use MediaWiki\Revision\ArchivedRevisionLookup; |
36 | use MediaWiki\Revision\RevisionRecord; |
37 | use MediaWiki\Revision\RevisionStore; |
38 | use MediaWiki\Status\Status; |
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\Message\ITextFormatter; |
46 | use Wikimedia\Message\MessageValue; |
47 | use Wikimedia\Rdbms\IConnectionProvider; |
48 | use Wikimedia\Rdbms\IDatabase; |
49 | use Wikimedia\Rdbms\ReadOnlyMode; |
50 | use WikiPage; |
51 | |
52 | /** |
53 | * Backend logic for performing a page undelete action. |
54 | * |
55 | * @since 1.38 |
56 | */ |
57 | class 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 | } |