1<?php 2/** 3 * Controller-like object for creating and updating pages by creating new revisions. 4 * 5 * This program is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation; either version 2 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License along 16 * with this program; if not, write to the Free Software Foundation, Inc., 17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 * http://www.gnu.org/copyleft/gpl.html 19 * 20 * @file 21 * 22 * @author Daniel Kinzler 23 */ 24 25namespace MediaWiki\Storage; 26 27use AtomicSectionUpdate; 28use CommentStoreComment; 29use Content; 30use ContentHandler; 31use DeferredUpdates; 32use InvalidArgumentException; 33use LogicException; 34use ManualLogEntry; 35use MediaWiki\Config\ServiceOptions; 36use MediaWiki\Content\IContentHandlerFactory; 37use MediaWiki\Debug\DeprecatablePropertyArray; 38use MediaWiki\HookContainer\HookContainer; 39use MediaWiki\HookContainer\HookRunner; 40use MediaWiki\Linker\LinkTarget; 41use MediaWiki\Permissions\Authority; 42use MediaWiki\Revision\MutableRevisionRecord; 43use MediaWiki\Revision\RevisionAccessException; 44use MediaWiki\Revision\RevisionRecord; 45use MediaWiki\Revision\RevisionStore; 46use MediaWiki\Revision\SlotRecord; 47use MediaWiki\Revision\SlotRoleRegistry; 48use MediaWiki\User\UserIdentity; 49use MWException; 50use RecentChange; 51use Revision; 52use RuntimeException; 53use Status; 54use Title; 55use User; 56use Wikimedia\Assert\Assert; 57use Wikimedia\Rdbms\DBConnRef; 58use Wikimedia\Rdbms\DBUnexpectedError; 59use Wikimedia\Rdbms\IDatabase; 60use Wikimedia\Rdbms\ILoadBalancer; 61use WikiPage; 62 63/** 64 * Controller-like object for creating and updating pages by creating new revisions. 65 * 66 * PageUpdater instances provide compare-and-swap (CAS) protection against concurrent updates 67 * between the time grabParentRevision() is called and saveRevision() inserts a new revision. 68 * This allows application logic to safely perform edit conflict resolution using the parent 69 * revision's content. 70 * 71 * @see docs/pageupdater.md for more information. 72 * 73 * MCR migration note: this replaces the relevant methods in WikiPage. 74 * 75 * @since 1.32 76 * @ingroup Page 77 * @phan-file-suppress PhanTypeArraySuspiciousNullable Cannot read type of $this->status->value 78 */ 79class PageUpdater { 80 81 /** 82 * Options that have to be present in the ServiceOptions object passed to the constructor. 83 * 84 * @internal 85 */ 86 public const CONSTRUCTOR_OPTIONS = [ 87 'ManualRevertSearchRadius', 88 'UseRCPatrol', 89 ]; 90 91 /** 92 * @var Authority 93 */ 94 private $performer; 95 96 /** 97 * @var WikiPage 98 */ 99 private $wikiPage; 100 101 /** 102 * @var DerivedPageDataUpdater 103 */ 104 private $derivedDataUpdater; 105 106 /** 107 * @var ILoadBalancer 108 */ 109 private $loadBalancer; 110 111 /** 112 * @var RevisionStore 113 */ 114 private $revisionStore; 115 116 /** 117 * @var SlotRoleRegistry 118 */ 119 private $slotRoleRegistry; 120 121 /** 122 * @var IContentHandlerFactory 123 */ 124 private $contentHandlerFactory; 125 126 /** 127 * @var HookRunner 128 */ 129 private $hookRunner; 130 131 /** 132 * @var HookContainer 133 */ 134 private $hookContainer; 135 136 /** 137 * @var bool see $wgUseAutomaticEditSummaries 138 * @see $wgUseAutomaticEditSummaries 139 */ 140 private $useAutomaticEditSummaries = true; 141 142 /** 143 * @var int the RC patrol status the new revision should be marked with. 144 */ 145 private $rcPatrolStatus = RecentChange::PRC_UNPATROLLED; 146 147 /** 148 * @var bool whether to create a log entry for new page creations. 149 */ 150 private $usePageCreationLog = true; 151 152 /** 153 * @var bool see $wgAjaxEditStash 154 */ 155 private $ajaxEditStash = true; 156 157 /** 158 * @var array 159 */ 160 private $tags = []; 161 162 /** 163 * @var RevisionSlotsUpdate 164 */ 165 private $slotsUpdate; 166 167 /** 168 * @var Status|null 169 */ 170 private $status = null; 171 172 /** 173 * @var EditResultBuilder 174 */ 175 private $editResultBuilder; 176 177 /** 178 * @var EditResult|null 179 */ 180 private $editResult = null; 181 182 /** 183 * @var string[] currently enabled software change tags 184 */ 185 private $softwareTags; 186 187 /** 188 * @var ServiceOptions 189 */ 190 private $serviceOptions; 191 192 /** 193 * @param Authority $performer 194 * @param WikiPage $wikiPage 195 * @param DerivedPageDataUpdater $derivedDataUpdater 196 * @param ILoadBalancer $loadBalancer 197 * @param RevisionStore $revisionStore 198 * @param SlotRoleRegistry $slotRoleRegistry 199 * @param IContentHandlerFactory $contentHandlerFactory 200 * @param HookContainer $hookContainer 201 * @param ServiceOptions $serviceOptions 202 * @param string[] $softwareTags Array of currently enabled software change tags. Can be 203 * obtained from ChangeTags::getSoftwareTags() 204 */ 205 public function __construct( 206 Authority $performer, 207 WikiPage $wikiPage, 208 DerivedPageDataUpdater $derivedDataUpdater, 209 ILoadBalancer $loadBalancer, 210 RevisionStore $revisionStore, 211 SlotRoleRegistry $slotRoleRegistry, 212 IContentHandlerFactory $contentHandlerFactory, 213 HookContainer $hookContainer, 214 ServiceOptions $serviceOptions, 215 array $softwareTags 216 ) { 217 $serviceOptions->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); 218 $this->serviceOptions = $serviceOptions; 219 220 $this->performer = $performer; 221 $this->wikiPage = $wikiPage; 222 $this->derivedDataUpdater = $derivedDataUpdater; 223 224 $this->loadBalancer = $loadBalancer; 225 $this->revisionStore = $revisionStore; 226 $this->slotRoleRegistry = $slotRoleRegistry; 227 $this->contentHandlerFactory = $contentHandlerFactory; 228 $this->hookContainer = $hookContainer; 229 $this->hookRunner = new HookRunner( $hookContainer ); 230 $this->softwareTags = $softwareTags; 231 232 $this->slotsUpdate = new RevisionSlotsUpdate(); 233 $this->editResultBuilder = new EditResultBuilder( 234 $revisionStore, 235 $softwareTags, 236 $loadBalancer, 237 new ServiceOptions( 238 EditResultBuilder::CONSTRUCTOR_OPTIONS, 239 [ 240 'ManualRevertSearchRadius' => 241 $serviceOptions->get( 'ManualRevertSearchRadius' ) 242 ] 243 ) 244 ); 245 } 246 247 /** 248 * @param UserIdentity $user 249 * 250 * @return User 251 */ 252 private static function toLegacyUser( UserIdentity $user ) { 253 return User::newFromIdentity( $user ); 254 } 255 256 /** 257 * Can be used to enable or disable automatic summaries that are applied to certain kinds of 258 * changes, like completely blanking a page. 259 * 260 * @param bool $useAutomaticEditSummaries 261 * @see $wgUseAutomaticEditSummaries 262 */ 263 public function setUseAutomaticEditSummaries( $useAutomaticEditSummaries ) { 264 $this->useAutomaticEditSummaries = $useAutomaticEditSummaries; 265 } 266 267 /** 268 * Sets the "patrolled" status of the edit. 269 * Callers should check the "patrol" and "autopatrol" permissions as appropriate. 270 * 271 * @see $wgUseRCPatrol 272 * @see $wgUseNPPatrol 273 * 274 * @param int $status RC patrol status, e.g. RecentChange::PRC_AUTOPATROLLED. 275 */ 276 public function setRcPatrolStatus( $status ) { 277 $this->rcPatrolStatus = $status; 278 } 279 280 /** 281 * Whether to create a log entry for new page creations. 282 * 283 * @see $wgPageCreationLog 284 * 285 * @param bool $use 286 */ 287 public function setUsePageCreationLog( $use ) { 288 $this->usePageCreationLog = $use; 289 } 290 291 /** 292 * @param bool $ajaxEditStash 293 * @see $wgAjaxEditStash 294 */ 295 public function setAjaxEditStash( $ajaxEditStash ) { 296 $this->ajaxEditStash = $ajaxEditStash; 297 } 298 299 private function getWikiId() { 300 return false; // TODO: get from RevisionStore! 301 } 302 303 /** 304 * @param int $mode DB_MASTER or DB_REPLICA 305 * 306 * @return DBConnRef 307 */ 308 private function getDBConnectionRef( $mode ) { 309 return $this->loadBalancer->getConnectionRef( $mode, [], $this->getWikiId() ); 310 } 311 312 /** 313 * @return LinkTarget 314 */ 315 private function getLinkTarget() { 316 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more 317 return $this->wikiPage->getTitle(); 318 } 319 320 /** 321 * @return Title 322 */ 323 private function getTitle() { 324 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more 325 return $this->wikiPage->getTitle(); 326 } 327 328 /** 329 * @return WikiPage 330 */ 331 private function getWikiPage() { 332 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more 333 return $this->wikiPage; 334 } 335 336 /** 337 * Checks whether this update conflicts with another update performed between the client 338 * loading data to prepare an edit, and the client committing the edit. This is intended to 339 * detect user level "edit conflict" when the latest revision known to the client 340 * is no longer the current revision when processing the update. 341 * 342 * An update expected to create a new page can be checked by setting $expectedParentRevision = 0. 343 * Such an update is considered to have a conflict if a current revision exists (that is, 344 * the page was created since the edit was initiated on the client). 345 * 346 * This method returning true indicates to calling code that edit conflict resolution should 347 * be applied before saving any data. It does not prevent the update from being performed, and 348 * it should not be confused with a "late" conflict indicated by the "edit-conflict" status. 349 * A "late" conflict is a CAS failure caused by an update being performed concurrently between 350 * the time grabParentRevision() was called and the time saveRevision() trying to insert the 351 * new revision. 352 * 353 * @note A user level edit conflict is not the same as the "edit-conflict" status triggered by 354 * a CAS failure. Calling this method establishes the CAS token, it does not check against it: 355 * This method calls grabParentRevision(), and thus causes the expected parent revision 356 * for the update to be fixed to the page's current revision at this point in time. 357 * It acts as a compare-and-swap (CAS) token in that it is guaranteed that saveRevision() 358 * will fail with the "edit-conflict" status if the current revision of the page changes after 359 * hasEditConflict() (or grabParentRevision()) was called and before saveRevision() could insert 360 * a new revision. 361 * 362 * @see grabParentRevision() 363 * 364 * @param int $expectedParentRevision The ID of the revision the client expects to be the 365 * current one. Use 0 to indicate that the page is expected to not yet exist. 366 * 367 * @return bool 368 */ 369 public function hasEditConflict( $expectedParentRevision ) { 370 $parent = $this->grabParentRevision(); 371 $parentId = $parent ? $parent->getId() : 0; 372 373 return $parentId !== $expectedParentRevision; 374 } 375 376 /** 377 * Returns the revision that was the page's current revision when grabParentRevision() 378 * was first called. This revision is the expected parent revision of the update, and will be 379 * recorded as the new revision's parent revision (unless no new revision is created because 380 * the content was not changed). 381 * 382 * This method MUST not be called after saveRevision() was called! 383 * 384 * The current revision determined by the first call to this method effectively acts a 385 * compare-and-swap (CAS) token which is checked by saveRevision(), which fails if any 386 * concurrent updates created a new revision. 387 * 388 * Application code should call this method before applying transformations to the new 389 * content that depend on the parent revision, e.g. adding/replacing sections, or resolving 390 * conflicts via a 3-way merge. This protects against race conditions triggered by concurrent 391 * updates. 392 * 393 * @see DerivedPageDataUpdater::grabCurrentRevision() 394 * 395 * @note The expected parent revision is not to be confused with the logical base revision. 396 * The base revision is specified by the client, the parent revision is determined from the 397 * database. If base revision and parent revision are not the same, the updates is considered 398 * to require edit conflict resolution. 399 * 400 * @throws LogicException if called after saveRevision(). 401 * @return RevisionRecord|null the parent revision, or null of the page does not yet exist. 402 */ 403 public function grabParentRevision() { 404 return $this->derivedDataUpdater->grabCurrentRevision(); 405 } 406 407 /** 408 * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed. 409 * 410 * @param int $flags 411 * @return int Updated $flags 412 */ 413 private function checkFlags( $flags ) { 414 if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) { 415 $flags |= ( $this->derivedDataUpdater->pageExisted() ) ? EDIT_UPDATE : EDIT_NEW; 416 } 417 418 return $flags; 419 } 420 421 /** 422 * Set the new content for the given slot role 423 * 424 * @param string $role A slot role name (such as "main") 425 * @param Content $content 426 */ 427 public function setContent( $role, Content $content ) { 428 $this->ensureRoleAllowed( $role ); 429 430 $this->slotsUpdate->modifyContent( $role, $content ); 431 } 432 433 /** 434 * Set the new slot for the given slot role 435 * 436 * @param SlotRecord $slot 437 */ 438 public function setSlot( SlotRecord $slot ) { 439 $this->ensureRoleAllowed( $slot->getRole() ); 440 441 $this->slotsUpdate->modifySlot( $slot ); 442 } 443 444 /** 445 * Explicitly inherit a slot from some earlier revision. 446 * 447 * The primary use case for this is rollbacks, when slots are to be inherited from 448 * the rollback target, overriding the content from the parent revision (which is the 449 * revision being rolled back). 450 * 451 * This should typically not be used to inherit slots from the parent revision, which 452 * happens implicitly. Using this method causes the given slot to be treated as "modified" 453 * during revision creation, even if it has the same content as in the parent revision. 454 * 455 * @param SlotRecord $originalSlot A slot already existing in the database, to be inherited 456 * by the new revision. 457 */ 458 public function inheritSlot( SlotRecord $originalSlot ) { 459 // NOTE: slots can be inherited even if the role is not "allowed" on the title. 460 // NOTE: this slot is inherited from some other revision, but it's 461 // a "modified" slot for the RevisionSlotsUpdate and DerivedPageDataUpdater, 462 // since it's not implicitly inherited from the parent revision. 463 $inheritedSlot = SlotRecord::newInherited( $originalSlot ); 464 $this->slotsUpdate->modifySlot( $inheritedSlot ); 465 } 466 467 /** 468 * Removes the slot with the given role. 469 * 470 * This discontinues the "stream" of slots with this role on the page, 471 * preventing the new revision, and any subsequent revisions, from 472 * inheriting the slot with this role. 473 * 474 * @param string $role A slot role name (but not "main") 475 */ 476 public function removeSlot( $role ) { 477 $this->ensureRoleNotRequired( $role ); 478 479 $this->slotsUpdate->removeSlot( $role ); 480 } 481 482 /** 483 * Sets the ID of an earlier revision that is being repeated or restored by this update. 484 * The new revision is expected to have the exact same content as the given original revision. 485 * This is used with rollbacks and with dummy "null" revisions which are created to record 486 * things like page moves. 487 * 488 * This value is passed to the PageContentSaveComplete and NewRevisionFromEditComplete hooks. 489 * 490 * @param int|bool $originalRevId The original revision id, or false if no earlier revision 491 * is known to be repeated or restored by this update. 492 */ 493 public function setOriginalRevisionId( $originalRevId ) { 494 $this->editResultBuilder->setOriginalRevisionId( $originalRevId ); 495 } 496 497 /** 498 * Marks this edit as a revert and applies relevant information. 499 * Will also cause the PageUpdater to add a relevant change tag when saving the edit. 500 * Will do nothing if $oldestRevertedRevId is 0. 501 * 502 * @param int $revertMethod The method used to make the revert: 503 * REVERT_UNDO, REVERT_ROLLBACK or REVERT_MANUAL 504 * @param int $oldestRevertedRevId The ID of the oldest revision that was reverted. 505 * @param int $newestRevertedRevId The ID of the newest revision that was reverted. This 506 * parameter is optional, default value is $oldestRevertedRevId 507 * 508 * @see EditResultBuilder::markAsRevert() 509 */ 510 public function markAsRevert( 511 int $revertMethod, 512 int $oldestRevertedRevId, 513 int $newestRevertedRevId = 0 514 ) { 515 $this->editResultBuilder->markAsRevert( 516 $revertMethod, $oldestRevertedRevId, $newestRevertedRevId 517 ); 518 } 519 520 /** 521 * Returns the EditResult associated with this PageUpdater. 522 * Will return null if PageUpdater::saveRevision() wasn't called yet. 523 * Will also return null if the update was not successful. 524 * 525 * @return EditResult|null 526 */ 527 public function getEditResult() : ?EditResult { 528 return $this->editResult; 529 } 530 531 /** 532 * Sets a tag to apply to this update. 533 * Callers are responsible for permission checks, 534 * using ChangeTags::canAddTagsAccompanyingChange. 535 * @param string $tag 536 */ 537 public function addTag( $tag ) { 538 Assert::parameterType( 'string', $tag, '$tag' ); 539 $this->tags[] = trim( $tag ); 540 } 541 542 /** 543 * Sets tags to apply to this update. 544 * Callers are responsible for permission checks, 545 * using ChangeTags::canAddTagsAccompanyingChange. 546 * @param string[] $tags 547 */ 548 public function addTags( array $tags ) { 549 Assert::parameterElementType( 'string', $tags, '$tags' ); 550 foreach ( $tags as $tag ) { 551 $this->addTag( $tag ); 552 } 553 } 554 555 /** 556 * Returns the list of tags set using the addTag() method. 557 * 558 * @return string[] 559 */ 560 public function getExplicitTags() { 561 return $this->tags; 562 } 563 564 /** 565 * @param int $flags Bit mask: a bit mask of EDIT_XXX flags. 566 * @return string[] 567 */ 568 private function computeEffectiveTags( $flags ) { 569 $tags = $this->tags; 570 $editResult = $this->getEditResult(); 571 572 foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) { 573 $old_content = $this->getParentContent( $role ); 574 575 $handler = $this->getContentHandler( $role ); 576 $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent(); 577 578 // TODO: MCR: Do this for all slots. Also add tags for removing roles! 579 $tag = $handler->getChangeTag( $old_content, $content, $flags ); 580 // If there is no applicable tag, null is returned, so we need to check 581 if ( $tag ) { 582 $tags[] = $tag; 583 } 584 } 585 586 $tags = array_merge( $tags, $editResult->getRevertTags() ); 587 588 return array_unique( $tags ); 589 } 590 591 /** 592 * Returns the content of the given slot of the parent revision, with no audience checks applied. 593 * If there is no parent revision or the slot is not defined, this returns null. 594 * 595 * @param string $role slot role name 596 * @return Content|null 597 */ 598 private function getParentContent( $role ) { 599 $parent = $this->grabParentRevision(); 600 601 if ( $parent && $parent->hasSlot( $role ) ) { 602 return $parent->getContent( $role, RevisionRecord::RAW ); 603 } 604 605 return null; 606 } 607 608 /** 609 * @param string $role slot role name 610 * @return ContentHandler 611 */ 612 private function getContentHandler( $role ) { 613 if ( $this->slotsUpdate->isModifiedSlot( $role ) ) { 614 $slot = $this->slotsUpdate->getModifiedSlot( $role ); 615 } else { 616 $parent = $this->grabParentRevision(); 617 618 if ( $parent ) { 619 $slot = $parent->getSlot( $role, RevisionRecord::RAW ); 620 } else { 621 throw new RevisionAccessException( 'No such slot: ' . $role ); 622 } 623 } 624 625 return $this->contentHandlerFactory->getContentHandler( $slot->getModel() ); 626 } 627 628 /** 629 * @param int $flags Bit mask: a bit mask of EDIT_XXX flags. 630 * 631 * @return CommentStoreComment 632 */ 633 private function makeAutoSummary( $flags ) { 634 if ( !$this->useAutomaticEditSummaries || ( $flags & EDIT_AUTOSUMMARY ) === 0 ) { 635 return CommentStoreComment::newUnsavedComment( '' ); 636 } 637 638 // NOTE: this generates an auto-summary for SOME RANDOM changed slot! 639 // TODO: combine auto-summaries for multiple slots! 640 // XXX: this logic should not be in the storage layer! 641 $roles = $this->slotsUpdate->getModifiedRoles(); 642 $role = reset( $roles ); 643 644 if ( $role === false ) { 645 return CommentStoreComment::newUnsavedComment( '' ); 646 } 647 648 $handler = $this->getContentHandler( $role ); 649 $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent(); 650 $old_content = $this->getParentContent( $role ); 651 $summary = $handler->getAutosummary( $old_content, $content, $flags ); 652 653 return CommentStoreComment::newUnsavedComment( $summary ); 654 } 655 656 /** 657 * Change an existing article or create a new article. Updates RC and all necessary caches, 658 * optionally via the deferred update array. This does not check user permissions. 659 * 660 * It is guaranteed that saveRevision() will fail if the current revision of the page 661 * changes after grabParentRevision() was called and before saveRevision() can insert 662 * a new revision, as per the CAS mechanism described above. 663 * 664 * The caller is however responsible for calling hasEditConflict() to detect a 665 * user-level edit conflict, and to adjust the content of the new revision accordingly, 666 * e.g. by using a 3-way-merge. 667 * 668 * MCR migration note: this replaces WikiPage::doEditContent. Callers that change to using 669 * saveRevision() now need to check the "minoredit" themselves before using EDIT_MINOR. 670 * 671 * @param CommentStoreComment $summary Edit summary 672 * @param int $flags Bitfield: 673 * EDIT_NEW 674 * Create a new page, or fail with "edit-already-exists" if the page exists. 675 * EDIT_UPDATE 676 * Create a new revision, or fail with "edit-gone-missing" if the page does not exist. 677 * EDIT_MINOR 678 * Mark this revision as minor 679 * EDIT_SUPPRESS_RC 680 * Do not log the change in recentchanges 681 * EDIT_FORCE_BOT 682 * Mark the revision as automated ("bot edit") 683 * EDIT_AUTOSUMMARY 684 * Fill in blank summaries with generated text where possible 685 * EDIT_INTERNAL 686 * Signal that the page retrieve/save cycle happened entirely in this request. 687 * 688 * If neither EDIT_NEW nor EDIT_UPDATE is specified, the expected state is detected 689 * automatically via grabParentRevision(). In this case, the "edit-already-exists" or 690 * "edit-gone-missing" errors may still be triggered due to race conditions, if the page 691 * was unexpectedly created or deleted while revision creation is in progress. This can be 692 * viewed as part of the CAS mechanism described above. 693 * 694 * @return RevisionRecord|null The new revision, or null if no new revision was created due 695 * to a failure or a null-edit. Use isUnchanged(), wasSuccessful() and getStatus() 696 * to determine the outcome of the revision creation. 697 * 698 * @throws MWException 699 * @throws RuntimeException 700 */ 701 public function saveRevision( CommentStoreComment $summary, $flags = 0 ) { 702 // Defend against mistakes caused by differences with the 703 // signature of WikiPage::doEditContent. 704 Assert::parameterType( 'integer', $flags, '$flags' ); 705 706 if ( $this->wasCommitted() ) { 707 throw new RuntimeException( 708 'saveRevision() or updateRevision() has already been called on this PageUpdater!' 709 ); 710 } 711 712 // Low-level sanity check 713 if ( $this->getLinkTarget()->getText() === '' ) { 714 throw new RuntimeException( 'Something is trying to edit an article with an empty title' ); 715 } 716 717 // NOTE: slots can be inherited even if the role is not "allowed" on the title. 718 $status = Status::newGood(); 719 $this->checkAllRolesAllowed( 720 $this->slotsUpdate->getModifiedRoles(), 721 $status 722 ); 723 $this->checkNoRolesRequired( 724 $this->slotsUpdate->getRemovedRoles(), 725 $status 726 ); 727 728 if ( !$status->isOK() ) { 729 return null; 730 } 731 732 // Make sure the given content is allowed in the respective slots of this page 733 foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) { 734 $slot = $this->slotsUpdate->getModifiedSlot( $role ); 735 $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role ); 736 737 if ( !$roleHandler->isAllowedModel( $slot->getModel(), $this->getTitle() ) ) { 738 $contentHandler = $this->contentHandlerFactory 739 ->getContentHandler( $slot->getModel() ); 740 $this->status = Status::newFatal( 'content-not-allowed-here', 741 ContentHandler::getLocalizedName( $contentHandler->getModelID() ), 742 $this->getTitle()->getPrefixedText(), 743 wfMessage( $roleHandler->getNameMessageKey() ) 744 // TODO: defer message lookup to caller 745 ); 746 return null; 747 } 748 } 749 750 // Load the data from the master database if needed. Needed to check flags. 751 // NOTE: This grabs the parent revision as the CAS token, if grabParentRevision 752 // wasn't called yet. If the page is modified by another process before we are done with 753 // it, this method must fail (with status 'edit-conflict')! 754 // NOTE: The parent revision may be different from $this->originalRevisionId. 755 $this->grabParentRevision(); 756 $flags = $this->checkFlags( $flags ); 757 758 // Avoid statsd noise and wasted cycles check the edit stash (T136678) 759 if ( ( $flags & EDIT_INTERNAL ) || ( $flags & EDIT_FORCE_BOT ) ) { 760 $useStashed = false; 761 } else { 762 $useStashed = $this->ajaxEditStash; 763 } 764 765 $user = $this->performer->getUser(); 766 $legacyUser = self::toLegacyUser( $user ); 767 768 // Prepare the update. This performs PST and generates the canonical ParserOutput. 769 $this->derivedDataUpdater->prepareContent( 770 $user, 771 $this->slotsUpdate, 772 $useStashed 773 ); 774 775 // Trigger pre-save hook (using provided edit summary) 776 $renderedRevision = $this->derivedDataUpdater->getRenderedRevision(); 777 $hookStatus = Status::newGood( [] ); 778 $allowedByHook = $this->hookRunner->onMultiContentSave( 779 $renderedRevision, $user, $summary, $flags, $hookStatus 780 ); 781 if ( $allowedByHook && $this->hookContainer->isRegistered( 'PageContentSave' ) ) { 782 // Also run the legacy hook. 783 // NOTE: WikiPage should only be used for the legacy hook, 784 // and only if something uses the legacy hook. 785 $mainContent = $this->derivedDataUpdater->getSlots()->getContent( SlotRecord::MAIN ); 786 787 // Deprecated since 1.35. 788 $allowedByHook = $this->hookRunner->onPageContentSave( 789 $this->getWikiPage(), $legacyUser, $mainContent, $summary, 790 $flags & EDIT_MINOR, null, null, $flags, $hookStatus 791 ); 792 } 793 794 if ( !$allowedByHook ) { 795 // The hook has prevented this change from being saved. 796 if ( $hookStatus->isOK() ) { 797 // Hook returned false but didn't call fatal(); use generic message 798 $hookStatus->fatal( 'edit-hook-aborted' ); 799 } 800 801 $this->status = $hookStatus; 802 return null; 803 } 804 805 // Provide autosummaries if one is not provided and autosummaries are enabled 806 // XXX: $summary == null seems logical, but the empty string may actually come from the user 807 // XXX: Move this logic out of the storage layer! It does not belong here! Use a callback? 808 if ( $summary->text === '' && $summary->data === null ) { 809 $summary = $this->makeAutoSummary( $flags ); 810 } 811 812 // Actually create the revision and create/update the page. 813 // Do NOT yet set $this->status! 814 if ( $flags & EDIT_UPDATE ) { 815 $status = $this->doModify( $summary, $user, $flags ); 816 } else { 817 $status = $this->doCreate( $summary, $user, $flags ); 818 } 819 820 // Promote user to any groups they meet the criteria for 821 DeferredUpdates::addCallableUpdate( static function () use ( $legacyUser ) { 822 $legacyUser->addAutopromoteOnceGroups( 'onEdit' ); 823 $legacyUser->addAutopromoteOnceGroups( 'onView' ); // b/c 824 } ); 825 826 // NOTE: set $this->status only after all hooks have been called, 827 // so wasCommitted doesn't return true when called indirectly from a hook handler! 828 $this->status = $status; 829 830 // TODO: replace bad status with Exceptions! 831 return ( $this->status && $this->status->isOK() ) 832 ? $this->status->value['revision-record'] 833 : null; 834 } 835 836 /** 837 * Updates derived slots of an existing article. Does not update RC. Updates all necessary 838 * caches, optionally via the deferred update array. This does not check user permissions. 839 * Does not do a PST. 840 * 841 * Use isUnchanged(), wasSuccessful() and getStatus() to determine the outcome of the 842 * revision update. 843 * 844 * @param int $revId 845 * @since 1.36 846 */ 847 public function updateRevision( int $revId = 0 ) { 848 if ( $this->wasCommitted() ) { 849 throw new RuntimeException( 850 'saveRevision() or updateRevision() has already been called on this PageUpdater!' 851 ); 852 } 853 854 // Low-level sanity check 855 if ( $this->getLinkTarget()->getText() === '' ) { 856 throw new RuntimeException( 'Something is trying to edit an article with an empty title' ); 857 } 858 859 $status = Status::newGood(); 860 $this->checkAllRolesAllowed( 861 $this->slotsUpdate->getModifiedRoles(), 862 $status 863 ); 864 $this->checkAllRolesDerived( 865 $this->slotsUpdate->getModifiedRoles(), 866 $status 867 ); 868 $this->checkAllRolesDerived( 869 $this->slotsUpdate->getRemovedRoles(), 870 $status 871 ); 872 873 if ( $revId === 0 ) { 874 $revision = $this->grabParentRevision(); 875 } else { 876 $revision = $this->revisionStore->getRevisionById( $revId, RevisionStore::READ_LATEST ); 877 } 878 if ( $revision === null ) { 879 $status->fatal( 'edit-gone-missing' ); 880 } 881 882 if ( !$status->isOK() ) { 883 $this->status = $status; 884 return; 885 } 886 887 // Make sure the given content is allowed in the respective slots of this page 888 foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) { 889 $slot = $this->slotsUpdate->getModifiedSlot( $role ); 890 $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role ); 891 892 if ( !$roleHandler->isAllowedModel( $slot->getModel(), $this->getTitle() ) ) { 893 $contentHandler = $this->contentHandlerFactory 894 ->getContentHandler( $slot->getModel() ); 895 $this->status = Status::newFatal( 896 'content-not-allowed-here', 897 ContentHandler::getLocalizedName( $contentHandler->getModelID() ), 898 $this->getTitle()->getPrefixedText(), 899 wfMessage( $roleHandler->getNameMessageKey() ) 900 // TODO: defer message lookup to caller 901 ); 902 return; 903 } 904 } 905 906 // do we need PST? 907 908 $this->status = $this->doUpdate( $this->performer->getUser(), $revision ); 909 } 910 911 /** 912 * Whether saveRevision() has been called on this instance 913 * 914 * @return bool 915 */ 916 public function wasCommitted() { 917 return $this->status !== null; 918 } 919 920 /** 921 * The Status object indicating whether saveRevision() was successful, or null if 922 * saveRevision() was not yet called on this instance. 923 * 924 * @note This is here for compatibility with WikiPage::doEditContent. It may be deprecated 925 * soon. 926 * 927 * Possible status errors: 928 * edit-hook-aborted: The ArticleSave hook aborted the update but didn't 929 * set the fatal flag of $status. 930 * edit-gone-missing: In update mode, but the article didn't exist. 931 * edit-conflict: In update mode, the article changed unexpectedly. 932 * edit-no-change: Warning that the text was the same as before. 933 * edit-already-exists: In creation mode, but the article already exists. 934 * 935 * Extensions may define additional errors. 936 * 937 * $return->value will contain an associative array with members as follows: 938 * new: Boolean indicating if the function attempted to create a new article. 939 * revision: The revision object for the inserted revision, or null. 940 * 941 * @return null|Status 942 */ 943 public function getStatus() { 944 return $this->status; 945 } 946 947 /** 948 * Whether saveRevision() completed successfully 949 * 950 * @return bool 951 */ 952 public function wasSuccessful() { 953 return $this->status && $this->status->isOK(); 954 } 955 956 /** 957 * Whether saveRevision() was called and created a new page. 958 * 959 * @return bool 960 */ 961 public function isNew() { 962 return $this->status && $this->status->isOK() && $this->status->value['new']; 963 } 964 965 /** 966 * Whether saveRevision() did not create a revision because the content didn't change 967 * (null-edit). Whether the content changed or not is determined by 968 * DerivedPageDataUpdater::isChange(). 969 * 970 * @return bool 971 */ 972 public function isUnchanged() { 973 return $this->status 974 && $this->status->isOK() 975 && $this->status->value['revision-record'] === null; 976 } 977 978 /** 979 * The new revision created by saveRevision(), or null if saveRevision() has not yet been 980 * called, failed, or did not create a new revision because the content did not change. 981 * 982 * @return RevisionRecord|null 983 */ 984 public function getNewRevision() { 985 return ( $this->status && $this->status->isOK() ) 986 ? $this->status->value['revision-record'] 987 : null; 988 } 989 990 /** 991 * Constructs a MutableRevisionRecord based on the Content prepared by the 992 * DerivedPageDataUpdater. This takes care of inheriting slots, updating slots 993 * with PST applied, and removing discontinued slots. 994 * 995 * This calls Content::prepareSave() to verify that the slot content can be saved. 996 * The $status parameter is updated with any errors or warnings found by Content::prepareSave(). 997 * 998 * @param CommentStoreComment $comment 999 * @param UserIdentity $user 1000 * @param int $flags 1001 * @param Status $status 1002 * 1003 * @return MutableRevisionRecord 1004 */ 1005 private function makeNewRevision( 1006 CommentStoreComment $comment, 1007 UserIdentity $user, 1008 $flags, 1009 Status $status 1010 ) { 1011 $wikiPage = $this->getWikiPage(); 1012 $title = $this->getTitle(); 1013 $parent = $this->grabParentRevision(); 1014 1015 // XXX: we expect to get a MutableRevisionRecord here, but that's a bit brittle! 1016 // TODO: introduce something like an UnsavedRevisionFactory service instead! 1017 /** @var MutableRevisionRecord $rev */ 1018 $rev = $this->derivedDataUpdater->getRevision(); 1019 '@phan-var MutableRevisionRecord $rev'; 1020 1021 // Avoid fatal error when the Title's ID changed, T204793 1022 if ( 1023 $rev->getPageId() !== null && $title->exists() 1024 && $rev->getPageId() !== $title->getArticleID() 1025 ) { 1026 $titlePageId = $title->getArticleID(); 1027 $revPageId = $rev->getPageId(); 1028 $masterPageId = $title->getArticleID( Title::READ_LATEST ); 1029 1030 if ( $revPageId === $masterPageId ) { 1031 wfWarn( __METHOD__ . ": Encountered stale Title object: old ID was $titlePageId, " 1032 . "continuing with new ID from master, $masterPageId" ); 1033 } else { 1034 throw new InvalidArgumentException( 1035 "Revision inherited page ID $revPageId from its parent, " 1036 . "but the provided Title object belongs to page ID $masterPageId" 1037 ); 1038 } 1039 } 1040 1041 $rev->setPageId( $title->getArticleID() ); 1042 1043 if ( $parent ) { 1044 $oldid = $parent->getId(); 1045 $rev->setParentId( $oldid ); 1046 } else { 1047 $oldid = 0; 1048 } 1049 1050 $rev->setComment( $comment ); 1051 $rev->setUser( $user ); 1052 $rev->setMinorEdit( ( $flags & EDIT_MINOR ) > 0 ); 1053 1054 foreach ( $rev->getSlots()->getSlots() as $slot ) { 1055 $content = $slot->getContent(); 1056 1057 // XXX: We may push this up to the "edit controller" level, see T192777. 1058 // XXX: prepareSave() and isValid() could live in SlotRoleHandler 1059 // XXX: PrepareSave should not take a WikiPage! 1060 $legacyUser = self::toLegacyUser( $user ); 1061 $prepStatus = $content->prepareSave( $wikiPage, $flags, $oldid, $legacyUser ); 1062 1063 // TODO: MCR: record which problem arose in which slot. 1064 $status->merge( $prepStatus ); 1065 } 1066 1067 $this->checkAllRequiredRoles( 1068 $rev->getSlotRoles(), 1069 $status 1070 ); 1071 1072 return $rev; 1073 } 1074 1075 /** 1076 * Builds the EditResult for this update. 1077 * Should be called by either doModify or doCreate. 1078 * 1079 * @param RevisionRecord $revision 1080 * @param bool $isNew 1081 */ 1082 private function buildEditResult( RevisionRecord $revision, bool $isNew ) { 1083 $this->editResultBuilder->setRevisionRecord( $revision ); 1084 $this->editResultBuilder->setIsNew( $isNew ); 1085 $this->editResult = $this->editResultBuilder->buildEditResult(); 1086 } 1087 1088 /** 1089 * Update derived slots in an existing revision. If the revision is the current revision, 1090 * this will update page_touched and trigger secondary updates. 1091 * 1092 * We do not have sufficient information to know whether to or how to update recentchanges 1093 * here, so, as opposed to doCreate(), updating recentchanges is left as the responsibility 1094 * of the caller. 1095 * 1096 * @param UserIdentity $user 1097 * @param RevisionRecord $revision 1098 * @return Status 1099 */ 1100 private function doUpdate( UserIdentity $user, RevisionRecord $revision ) : Status { 1101 $currentRevision = $this->grabParentRevision(); 1102 if ( !$currentRevision ) { 1103 // Article gone missing 1104 return Status::newFatal( 'edit-gone-missing' ); 1105 } 1106 1107 $dbw = $this->getDBConnectionRef( DB_MASTER ); 1108 $dbw->startAtomic( __METHOD__ ); 1109 1110 $slots = $this->revisionStore->updateslotsOn( $revision, $this->slotsUpdate, $dbw ); 1111 1112 $dbw->endAtomic( __METHOD__ ); 1113 1114 // Return the slots and revision to the caller 1115 $newRevisionRecord = MutableRevisionRecord::newUpdatedRevisionRecord( $revision, $slots ); 1116 $status = Status::newGood( [ 1117 'revision-record' => $newRevisionRecord, 1118 'slots' => $slots, 1119 ] ); 1120 1121 $isCurrent = $revision->getId( $this->getWikiId() ) === 1122 $currentRevision->getId( $this->getWikiId() ); 1123 1124 if ( $isCurrent ) { 1125 // Update page_touched 1126 $this->getTitle()->invalidateCache( $newRevisionRecord->getTimestamp() ); 1127 1128 $this->buildEditResult( $newRevisionRecord, false ); 1129 1130 // Do secondary updates once the main changes have been committed... 1131 $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only! 1132 DeferredUpdates::addUpdate( 1133 $this->getAtomicSectionUpdate( 1134 $dbw, 1135 $wikiPage, 1136 $newRevisionRecord, 1137 $user, 1138 $revision->getComment(), 1139 EDIT_INTERNAL, 1140 $status, 1141 [ 'changed' => false, ] 1142 ), 1143 DeferredUpdates::PRESEND 1144 ); 1145 } 1146 1147 return $status; 1148 } 1149 1150 /** 1151 * @param CommentStoreComment $summary The edit summary 1152 * @param UserIdentity $user The revision's author 1153 * @param int $flags EDIT_XXX constants 1154 * 1155 * @throws MWException 1156 * @return Status 1157 */ 1158 private function doModify( CommentStoreComment $summary, UserIdentity $user, $flags ) { 1159 $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only! 1160 1161 // Update article, but only if changed. 1162 $status = Status::newGood( 1163 new DeprecatablePropertyArray( 1164 [ 'new' => false, 'revision' => null, 'revision-record' => null ], 1165 [ 'revision' => '1.35' ], 1166 __METHOD__ . ' status' 1167 ) 1168 ); 1169 1170 $oldRev = $this->grabParentRevision(); 1171 $oldid = $oldRev ? $oldRev->getId() : 0; 1172 1173 if ( !$oldRev ) { 1174 // Article gone missing 1175 $status->fatal( 'edit-gone-missing' ); 1176 1177 return $status; 1178 } 1179 1180 $newRevisionRecord = $this->makeNewRevision( 1181 $summary, 1182 $user, 1183 $flags, 1184 $status 1185 ); 1186 1187 if ( !$status->isOK() ) { 1188 return $status; 1189 } 1190 1191 $now = $newRevisionRecord->getTimestamp(); 1192 1193 // XXX: we may want a flag that allows a null revision to be forced! 1194 $changed = $this->derivedDataUpdater->isChange(); 1195 1196 // We build the EditResult before the $change if/else branch in order to pass 1197 // the correct $newRevisionRecord to EditResultBuilder. In case this is a null 1198 // edit, $newRevisionRecord will be later overridden to its parent revision, which 1199 // would confuse EditResultBuilder. 1200 if ( !$changed ) { 1201 // This is a null edit, ensure original revision ID is set properly 1202 $this->editResultBuilder->setOriginalRevisionId( $oldid ); 1203 } 1204 $this->buildEditResult( $newRevisionRecord, false ); 1205 1206 $legacyUser = self::toLegacyUser( $user ); 1207 1208 $dbw = $this->getDBConnectionRef( DB_MASTER ); 1209 1210 if ( $changed ) { 1211 $dbw->startAtomic( __METHOD__ ); 1212 1213 // Get the latest page_latest value while locking it. 1214 // Do a CAS style check to see if it's the same as when this method 1215 // started. If it changed then bail out before touching the DB. 1216 $latestNow = $wikiPage->lockAndGetLatest(); // TODO: move to storage service, pass DB 1217 if ( $latestNow != $oldid ) { 1218 // We don't need to roll back, since we did not modify the database yet. 1219 // XXX: Or do we want to rollback, any transaction started by calling 1220 // code will fail? If we want that, we should probably throw an exception. 1221 $dbw->endAtomic( __METHOD__ ); 1222 // Page updated or deleted in the mean time 1223 $status->fatal( 'edit-conflict' ); 1224 1225 return $status; 1226 } 1227 1228 // At this point we are now comitted to returning an OK 1229 // status unless some DB query error or other exception comes up. 1230 // This way callers don't have to call rollback() if $status is bad 1231 // unless they actually try to catch exceptions (which is rare). 1232 1233 // Save revision content and meta-data 1234 $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw ); 1235 1236 // Update page_latest and friends to reflect the new revision 1237 // TODO: move to storage service 1238 $wasRedirect = $this->derivedDataUpdater->wasRedirect(); 1239 if ( !$wikiPage->updateRevisionOn( $dbw, $newRevisionRecord, null, $wasRedirect ) ) { 1240 throw new PageUpdateException( "Failed to update page row to use new revision." ); 1241 } 1242 1243 $editResult = $this->getEditResult(); 1244 $tags = $this->computeEffectiveTags( $flags ); 1245 $this->hookRunner->onRevisionFromEditComplete( 1246 $wikiPage, $newRevisionRecord, $editResult->getOriginalRevisionId(), $user, $tags 1247 ); 1248 1249 // Hook is hard deprecated since 1.35 1250 if ( $this->hookContainer->isRegistered( 'NewRevisionFromEditComplete' ) ) { 1251 // Only create Revision object if needed 1252 $newLegacyRevision = new Revision( $newRevisionRecord ); 1253 $this->hookRunner->onNewRevisionFromEditComplete( 1254 $wikiPage, 1255 $newLegacyRevision, 1256 $editResult->getOriginalRevisionId(), 1257 $legacyUser, 1258 $tags 1259 ); 1260 } 1261 1262 // Update recentchanges 1263 if ( !( $flags & EDIT_SUPPRESS_RC ) ) { 1264 // Add RC row to the DB 1265 RecentChange::notifyEdit( 1266 $now, 1267 $this->getTitle(), 1268 $newRevisionRecord->isMinor(), 1269 $legacyUser, 1270 $summary->text, // TODO: pass object when that becomes possible 1271 $oldid, 1272 $newRevisionRecord->getTimestamp(), 1273 ( $flags & EDIT_FORCE_BOT ) > 0, 1274 '', 1275 $oldRev->getSize(), 1276 $newRevisionRecord->getSize(), 1277 $newRevisionRecord->getId(), 1278 $this->rcPatrolStatus, 1279 $tags, 1280 $editResult 1281 ); 1282 } 1283 1284 $legacyUser->incEditCount(); 1285 1286 $dbw->endAtomic( __METHOD__ ); 1287 1288 // Return the new revision to the caller 1289 $status->value['revision-record'] = $newRevisionRecord; 1290 1291 // Deprecated via DeprecatablePropertyArray 1292 $status->value['revision'] = static function () use ( $newRevisionRecord ) { 1293 return new Revision( $newRevisionRecord ); 1294 }; 1295 } else { 1296 // T34948: revision ID must be set to page {{REVISIONID}} and 1297 // related variables correctly. Likewise for {{REVISIONUSER}} (T135261). 1298 // Since we don't insert a new revision into the database, the least 1299 // error-prone way is to reuse given old revision. 1300 $newRevisionRecord = $oldRev; 1301 1302 $status->warning( 'edit-no-change' ); 1303 // Update page_touched as updateRevisionOn() was not called. 1304 // Other cache updates are managed in WikiPage::onArticleEdit() 1305 // via WikiPage::doEditUpdates(). 1306 $this->getTitle()->invalidateCache( $now ); 1307 } 1308 1309 // Do secondary updates once the main changes have been committed... 1310 // NOTE: the updates have to be processed before sending the response to the client 1311 // (DeferredUpdates::PRESEND), otherwise the client may already be following the 1312 // HTTP redirect to the standard view before derived data has been created - most 1313 // importantly, before the parser cache has been updated. This would cause the 1314 // content to be parsed a second time, or may cause stale content to be shown. 1315 DeferredUpdates::addUpdate( 1316 $this->getAtomicSectionUpdate( 1317 $dbw, 1318 $wikiPage, 1319 $newRevisionRecord, 1320 $user, 1321 $summary, 1322 $flags, 1323 $status, 1324 [ 'changed' => $changed, ] 1325 ), 1326 DeferredUpdates::PRESEND 1327 ); 1328 1329 return $status; 1330 } 1331 1332 /** 1333 * @param CommentStoreComment $summary The edit summary 1334 * @param UserIdentity $user The revision's author 1335 * @param int $flags EDIT_XXX constants 1336 * 1337 * @throws DBUnexpectedError 1338 * @throws MWException 1339 * @return Status 1340 */ 1341 private function doCreate( CommentStoreComment $summary, UserIdentity $user, $flags ) { 1342 $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only! 1343 1344 if ( !$this->derivedDataUpdater->getSlots()->hasSlot( SlotRecord::MAIN ) ) { 1345 throw new PageUpdateException( 'Must provide a main slot when creating a page!' ); 1346 } 1347 1348 $status = Status::newGood( 1349 new DeprecatablePropertyArray( 1350 [ 'new' => true, 'revision' => null, 'revision-record' => null ], 1351 [ 'revision' => '1.35' ], 1352 __METHOD__ . ' status' 1353 ) 1354 ); 1355 1356 $newRevisionRecord = $this->makeNewRevision( 1357 $summary, 1358 $user, 1359 $flags, 1360 $status 1361 ); 1362 1363 if ( !$status->isOK() ) { 1364 return $status; 1365 } 1366 1367 $this->buildEditResult( $newRevisionRecord, true ); 1368 $now = $newRevisionRecord->getTimestamp(); 1369 1370 $dbw = $this->getDBConnectionRef( DB_MASTER ); 1371 $dbw->startAtomic( __METHOD__ ); 1372 1373 // Add the page record unless one already exists for the title 1374 // TODO: move to storage service 1375 $newid = $wikiPage->insertOn( $dbw ); 1376 if ( $newid === false ) { 1377 $dbw->endAtomic( __METHOD__ ); 1378 $status->fatal( 'edit-already-exists' ); 1379 1380 return $status; 1381 } 1382 1383 // At this point we are now comitted to returning an OK 1384 // status unless some DB query error or other exception comes up. 1385 // This way callers don't have to call rollback() if $status is bad 1386 // unless they actually try to catch exceptions (which is rare). 1387 $newRevisionRecord->setPageId( $newid ); 1388 1389 // Save the revision text... 1390 $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw ); 1391 1392 // Update the page record with revision data 1393 // TODO: move to storage service 1394 if ( !$wikiPage->updateRevisionOn( $dbw, $newRevisionRecord, 0 ) ) { 1395 throw new PageUpdateException( "Failed to update page row to use new revision." ); 1396 } 1397 1398 $tags = $this->computeEffectiveTags( $flags ); 1399 $this->hookRunner->onRevisionFromEditComplete( 1400 $wikiPage, $newRevisionRecord, false, $user, $tags 1401 ); 1402 1403 $legacyUser = self::toLegacyUser( $user ); 1404 1405 // Hook is deprecated since 1.35 1406 if ( $this->hookContainer->isRegistered( 'NewRevisionFromEditComplete' ) ) { 1407 // ONly create Revision object if needed 1408 $newLegacyRevision = new Revision( $newRevisionRecord ); 1409 $this->hookRunner->onNewRevisionFromEditComplete( 1410 $wikiPage, 1411 $newLegacyRevision, 1412 false, 1413 $legacyUser, 1414 $tags 1415 ); 1416 } 1417 1418 // Update recentchanges 1419 if ( !( $flags & EDIT_SUPPRESS_RC ) ) { 1420 // Add RC row to the DB 1421 RecentChange::notifyNew( 1422 $now, 1423 $this->getTitle(), 1424 $newRevisionRecord->isMinor(), 1425 $legacyUser, 1426 $summary->text, // TODO: pass object when that becomes possible 1427 ( $flags & EDIT_FORCE_BOT ) > 0, 1428 '', 1429 $newRevisionRecord->getSize(), 1430 $newRevisionRecord->getId(), 1431 $this->rcPatrolStatus, 1432 $tags 1433 ); 1434 } 1435 1436 $legacyUser->incEditCount(); 1437 1438 if ( $this->usePageCreationLog ) { 1439 // Log the page creation 1440 // @TODO: Do we want a 'recreate' action? 1441 $logEntry = new ManualLogEntry( 'create', 'create' ); 1442 $logEntry->setPerformer( $user ); 1443 $logEntry->setTarget( $this->getTitle() ); 1444 $logEntry->setComment( $summary->text ); 1445 $logEntry->setTimestamp( $now ); 1446 $logEntry->setAssociatedRevId( $newRevisionRecord->getId() ); 1447 $logEntry->insert(); 1448 // Note that we don't publish page creation events to recentchanges 1449 // (i.e. $logEntry->publish()) since this would create duplicate entries, 1450 // one for the edit and one for the page creation. 1451 } 1452 1453 $dbw->endAtomic( __METHOD__ ); 1454 1455 // Return the new revision to the caller 1456 $status->value['revision-record'] = $newRevisionRecord; 1457 1458 // Deprecated via DeprecatablePropertyArray 1459 $status->value['revision'] = static function () use ( $newRevisionRecord ) { 1460 return new Revision( $newRevisionRecord ); 1461 }; 1462 1463 // Do secondary updates once the main changes have been committed... 1464 DeferredUpdates::addUpdate( 1465 $this->getAtomicSectionUpdate( 1466 $dbw, 1467 $wikiPage, 1468 $newRevisionRecord, 1469 $user, 1470 $summary, 1471 $flags, 1472 $status, 1473 [ 'created' => true ] 1474 ), 1475 DeferredUpdates::PRESEND 1476 ); 1477 1478 return $status; 1479 } 1480 1481 private function getAtomicSectionUpdate( 1482 IDatabase $dbw, 1483 WikiPage $wikiPage, 1484 RevisionRecord $newRevisionRecord, 1485 UserIdentity $user, 1486 CommentStoreComment $summary, 1487 $flags, 1488 Status $status, 1489 $hints = [] 1490 ) { 1491 return new AtomicSectionUpdate( 1492 $dbw, 1493 __METHOD__, 1494 function () use ( 1495 $wikiPage, $newRevisionRecord, $user, 1496 $summary, $flags, $status, $hints 1497 ) { 1498 // set debug data 1499 $hints['causeAction'] = 'edit-page'; 1500 $hints['causeAgent'] = $user->getName(); 1501 1502 $editResult = $this->getEditResult(); 1503 $hints['editResult'] = $editResult; 1504 1505 if ( $editResult->isRevert() ) { 1506 // Should the reverted tag update be scheduled right away? 1507 // The revert is approved if either patrolling is disabled or the 1508 // edit is patrolled or autopatrolled. 1509 $approved = !$this->serviceOptions->get( 'UseRCPatrol' ) || 1510 $this->rcPatrolStatus === RecentChange::PRC_PATROLLED || 1511 $this->rcPatrolStatus === RecentChange::PRC_AUTOPATROLLED; 1512 1513 // Allow extensions to override the patrolling subsystem. 1514 $this->hookRunner->onBeforeRevertedTagUpdate( 1515 $wikiPage, 1516 $user, 1517 $summary, 1518 $flags, 1519 $newRevisionRecord, 1520 $editResult, 1521 $approved 1522 ); 1523 $hints['approved'] = $approved; 1524 } 1525 1526 // Update links tables, site stats, etc. 1527 $this->derivedDataUpdater->prepareUpdate( $newRevisionRecord, $hints ); 1528 $this->derivedDataUpdater->doUpdates(); 1529 1530 $created = $hints['created'] ?? false; 1531 $flags |= ( $created ? EDIT_NEW : EDIT_UPDATE ); 1532 1533 // PageSaveComplete replaces the other two since 1.35 1534 $this->hookRunner->onPageSaveComplete( 1535 $wikiPage, 1536 $user, 1537 $summary->text, 1538 $flags, 1539 $newRevisionRecord, 1540 $editResult 1541 ); 1542 1543 // Both hooks are hard deprecated since 1.35 1544 if ( !$this->hookContainer->isRegistered( 'PageContentInsertComplete' ) 1545 && !$this->hookContainer->isRegistered( 'PageContentSaveComplete' ) 1546 ) { 1547 // Don't go on to create a Revision unless its needed 1548 return; 1549 } 1550 1551 $legacyUser = self::toLegacyUser( $user ); 1552 1553 $mainContent = $newRevisionRecord->getContent( SlotRecord::MAIN, RevisionRecord::RAW ); 1554 $newLegacyRevision = new Revision( $newRevisionRecord ); 1555 if ( $created ) { 1556 // Trigger post-create hook 1557 $this->hookRunner->onPageContentInsertComplete( $wikiPage, $legacyUser, 1558 $mainContent, $summary->text, $flags & EDIT_MINOR, 1559 null, null, $flags, $newLegacyRevision ); 1560 } 1561 1562 // Trigger post-save hook 1563 $this->hookRunner->onPageContentSaveComplete( $wikiPage, $legacyUser, $mainContent, 1564 $summary->text, $flags & EDIT_MINOR, null, 1565 null, $flags, $newLegacyRevision, $status, 1566 $editResult->getOriginalRevisionId(), $editResult->getUndidRevId() ); 1567 } 1568 ); 1569 } 1570 1571 /** 1572 * @return string[] Slots required for this page update, as a list of role names. 1573 */ 1574 private function getRequiredSlotRoles() { 1575 return $this->slotRoleRegistry->getRequiredRoles( $this->getTitle() ); 1576 } 1577 1578 /** 1579 * @return string[] Slots allowed for this page update, as a list of role names. 1580 */ 1581 private function getAllowedSlotRoles() { 1582 return $this->slotRoleRegistry->getAllowedRoles( $this->getTitle() ); 1583 } 1584 1585 private function ensureRoleAllowed( $role ) { 1586 $allowedRoles = $this->getAllowedSlotRoles(); 1587 if ( !in_array( $role, $allowedRoles ) ) { 1588 throw new PageUpdateException( "Slot role `$role` is not allowed." ); 1589 } 1590 } 1591 1592 private function ensureRoleNotRequired( $role ) { 1593 $requiredRoles = $this->getRequiredSlotRoles(); 1594 if ( in_array( $role, $requiredRoles ) ) { 1595 throw new PageUpdateException( "Slot role `$role` is required." ); 1596 } 1597 } 1598 1599 /** 1600 * @param array $roles 1601 * @param Status $status 1602 */ 1603 private function checkAllRolesAllowed( array $roles, Status $status ) { 1604 $allowedRoles = $this->getAllowedSlotRoles(); 1605 1606 $forbidden = array_diff( $roles, $allowedRoles ); 1607 if ( !empty( $forbidden ) ) { 1608 $status->error( 1609 'edit-slots-cannot-add', 1610 count( $forbidden ), 1611 implode( ', ', $forbidden ) 1612 ); 1613 } 1614 } 1615 1616 /** 1617 * @param array $roles 1618 * @param Status $status 1619 */ 1620 private function checkAllRolesDerived( array $roles, Status $status ) { 1621 $notDerived = array_filter( 1622 $roles, 1623 function ( $role ) { 1624 return !$this->slotRoleRegistry->getRoleHandler( $role )->isDerived(); 1625 } 1626 ); 1627 if ( $notDerived ) { 1628 $status->error( 1629 'edit-slots-not-derived', 1630 count( $notDerived ), 1631 implode( ', ', $notDerived ) 1632 ); 1633 } 1634 } 1635 1636 /** 1637 * @param array $roles 1638 * @param Status $status 1639 */ 1640 private function checkNoRolesRequired( array $roles, Status $status ) { 1641 $requiredRoles = $this->getRequiredSlotRoles(); 1642 1643 $needed = array_diff( $roles, $requiredRoles ); 1644 if ( !empty( $needed ) ) { 1645 $status->error( 1646 'edit-slots-cannot-remove', 1647 count( $needed ), 1648 implode( ', ', $needed ) 1649 ); 1650 } 1651 } 1652 1653 /** 1654 * @param array $roles 1655 * @param Status $status 1656 */ 1657 private function checkAllRequiredRoles( array $roles, Status $status ) { 1658 $requiredRoles = $this->getRequiredSlotRoles(); 1659 1660 $missing = array_diff( $requiredRoles, $roles ); 1661 if ( !empty( $missing ) ) { 1662 $status->error( 1663 'edit-slots-missing', 1664 count( $missing ), 1665 implode( ', ', $missing ) 1666 ); 1667 } 1668 } 1669 1670} 1671