1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312 |
- <?php
- /**
- * Controller-like object for creating and updating pages by creating new revisions.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- *
- * @author Daniel Kinzler
- */
- namespace MediaWiki\Storage;
- use AtomicSectionUpdate;
- use ChangeTags;
- use CommentStoreComment;
- use Content;
- use ContentHandler;
- use DeferredUpdates;
- use Hooks;
- use LogicException;
- use ManualLogEntry;
- use MediaWiki\Linker\LinkTarget;
- use MediaWiki\Revision\MutableRevisionRecord;
- use MediaWiki\Revision\RevisionAccessException;
- use MediaWiki\Revision\RevisionRecord;
- use MediaWiki\Revision\RevisionStore;
- use MediaWiki\Revision\SlotRoleRegistry;
- use MediaWiki\Revision\SlotRecord;
- use MWException;
- use RecentChange;
- use Revision;
- use RuntimeException;
- use Status;
- use Title;
- use User;
- use Wikimedia\Assert\Assert;
- use Wikimedia\Rdbms\DBConnRef;
- use Wikimedia\Rdbms\DBUnexpectedError;
- use Wikimedia\Rdbms\IDatabase;
- use Wikimedia\Rdbms\ILoadBalancer;
- use WikiPage;
- /**
- * Controller-like object for creating and updating pages by creating new revisions.
- *
- * PageUpdater instances provide compare-and-swap (CAS) protection against concurrent updates
- * between the time grabParentRevision() is called and saveRevision() inserts a new revision.
- * This allows application logic to safely perform edit conflict resolution using the parent
- * revision's content.
- *
- * @see docs/pageupdater.txt for more information.
- *
- * MCR migration note: this replaces the relevant methods in WikiPage.
- *
- * @since 1.32
- * @ingroup Page
- */
- class PageUpdater {
- /**
- * @var User
- */
- private $user;
- /**
- * @var WikiPage
- */
- private $wikiPage;
- /**
- * @var DerivedPageDataUpdater
- */
- private $derivedDataUpdater;
- /**
- * @var ILoadBalancer
- */
- private $loadBalancer;
- /**
- * @var RevisionStore
- */
- private $revisionStore;
- /**
- * @var SlotRoleRegistry
- */
- private $slotRoleRegistry;
- /**
- * @var boolean see $wgUseAutomaticEditSummaries
- * @see $wgUseAutomaticEditSummaries
- */
- private $useAutomaticEditSummaries = true;
- /**
- * @var int the RC patrol status the new revision should be marked with.
- */
- private $rcPatrolStatus = RecentChange::PRC_UNPATROLLED;
- /**
- * @var bool whether to create a log entry for new page creations.
- */
- private $usePageCreationLog = true;
- /**
- * @var boolean see $wgAjaxEditStash
- */
- private $ajaxEditStash = true;
- /**
- * @var bool|int
- */
- private $originalRevId = false;
- /**
- * @var array
- */
- private $tags = [];
- /**
- * @var int
- */
- private $undidRevId = 0;
- /**
- * @var RevisionSlotsUpdate
- */
- private $slotsUpdate;
- /**
- * @var Status|null
- */
- private $status = null;
- /**
- * @param User $user
- * @param WikiPage $wikiPage
- * @param DerivedPageDataUpdater $derivedDataUpdater
- * @param ILoadBalancer $loadBalancer
- * @param RevisionStore $revisionStore
- * @param SlotRoleRegistry $slotRoleRegistry
- */
- public function __construct(
- User $user,
- WikiPage $wikiPage,
- DerivedPageDataUpdater $derivedDataUpdater,
- ILoadBalancer $loadBalancer,
- RevisionStore $revisionStore,
- SlotRoleRegistry $slotRoleRegistry
- ) {
- $this->user = $user;
- $this->wikiPage = $wikiPage;
- $this->derivedDataUpdater = $derivedDataUpdater;
- $this->loadBalancer = $loadBalancer;
- $this->revisionStore = $revisionStore;
- $this->slotRoleRegistry = $slotRoleRegistry;
- $this->slotsUpdate = new RevisionSlotsUpdate();
- }
- /**
- * Can be used to enable or disable automatic summaries that are applied to certain kinds of
- * changes, like completely blanking a page.
- *
- * @param bool $useAutomaticEditSummaries
- * @see $wgUseAutomaticEditSummaries
- */
- public function setUseAutomaticEditSummaries( $useAutomaticEditSummaries ) {
- $this->useAutomaticEditSummaries = $useAutomaticEditSummaries;
- }
- /**
- * Sets the "patrolled" status of the edit.
- * Callers should check the "patrol" and "autopatrol" permissions as appropriate.
- *
- * @see $wgUseRCPatrol
- * @see $wgUseNPPatrol
- *
- * @param int $status RC patrol status, e.g. RecentChange::PRC_AUTOPATROLLED.
- */
- public function setRcPatrolStatus( $status ) {
- $this->rcPatrolStatus = $status;
- }
- /**
- * Whether to create a log entry for new page creations.
- *
- * @see $wgPageCreationLog
- *
- * @param bool $use
- */
- public function setUsePageCreationLog( $use ) {
- $this->usePageCreationLog = $use;
- }
- /**
- * @param bool $ajaxEditStash
- * @see $wgAjaxEditStash
- */
- public function setAjaxEditStash( $ajaxEditStash ) {
- $this->ajaxEditStash = $ajaxEditStash;
- }
- private function getWikiId() {
- return false; // TODO: get from RevisionStore!
- }
- /**
- * @param int $mode DB_MASTER or DB_REPLICA
- *
- * @return DBConnRef
- */
- private function getDBConnectionRef( $mode ) {
- return $this->loadBalancer->getConnectionRef( $mode, [], $this->getWikiId() );
- }
- /**
- * @return LinkTarget
- */
- private function getLinkTarget() {
- // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
- return $this->wikiPage->getTitle();
- }
- /**
- * @return Title
- */
- private function getTitle() {
- // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
- return $this->wikiPage->getTitle();
- }
- /**
- * @return WikiPage
- */
- private function getWikiPage() {
- // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
- return $this->wikiPage;
- }
- /**
- * Checks whether this update conflicts with another update performed between the client
- * loading data to prepare an edit, and the client committing the edit. This is intended to
- * detect user level "edit conflict" when the latest revision known to the client
- * is no longer the current revision when processing the update.
- *
- * An update expected to create a new page can be checked by setting $expectedParentRevision = 0.
- * Such an update is considered to have a conflict if a current revision exists (that is,
- * the page was created since the edit was initiated on the client).
- *
- * This method returning true indicates to calling code that edit conflict resolution should
- * be applied before saving any data. It does not prevent the update from being performed, and
- * it should not be confused with a "late" conflict indicated by the "edit-conflict" status.
- * A "late" conflict is a CAS failure caused by an update being performed concurrently between
- * the time grabParentRevision() was called and the time saveRevision() trying to insert the
- * new revision.
- *
- * @note A user level edit conflict is not the same as the "edit-conflict" status triggered by
- * a CAS failure. Calling this method establishes the CAS token, it does not check against it:
- * This method calls grabParentRevision(), and thus causes the expected parent revision
- * for the update to be fixed to the page's current revision at this point in time.
- * It acts as a compare-and-swap (CAS) token in that it is guaranteed that saveRevision()
- * will fail with the "edit-conflict" status if the current revision of the page changes after
- * hasEditConflict() (or grabParentRevision()) was called and before saveRevision() could insert
- * a new revision.
- *
- * @see grabParentRevision()
- *
- * @param int $expectedParentRevision The ID of the revision the client expects to be the
- * current one. Use 0 to indicate that the page is expected to not yet exist.
- *
- * @return bool
- */
- public function hasEditConflict( $expectedParentRevision ) {
- $parent = $this->grabParentRevision();
- $parentId = $parent ? $parent->getId() : 0;
- return $parentId !== $expectedParentRevision;
- }
- /**
- * Returns the revision that was the page's current revision when grabParentRevision()
- * was first called. This revision is the expected parent revision of the update, and will be
- * recorded as the new revision's parent revision (unless no new revision is created because
- * the content was not changed).
- *
- * This method MUST not be called after saveRevision() was called!
- *
- * The current revision determined by the first call to this methods effectively acts a
- * compare-and-swap (CAS) token which is checked by saveRevision(), which fails if any
- * concurrent updates created a new revision.
- *
- * Application code should call this method before applying transformations to the new
- * content that depend on the parent revision, e.g. adding/replacing sections, or resolving
- * conflicts via a 3-way merge. This protects against race conditions triggered by concurrent
- * updates.
- *
- * @see DerivedPageDataUpdater::grabCurrentRevision()
- *
- * @note The expected parent revision is not to be confused with the logical base revision.
- * The base revision is specified by the client, the parent revision is determined from the
- * database. If base revision and parent revision are not the same, the updates is considered
- * to require edit conflict resolution.
- *
- * @throws LogicException if called after saveRevision().
- * @return RevisionRecord|null the parent revision, or null of the page does not yet exist.
- */
- public function grabParentRevision() {
- return $this->derivedDataUpdater->grabCurrentRevision();
- }
- /**
- * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
- *
- * @param int $flags
- * @return int Updated $flags
- */
- private function checkFlags( $flags ) {
- if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) {
- $flags |= ( $this->derivedDataUpdater->pageExisted() ) ? EDIT_UPDATE : EDIT_NEW;
- }
- return $flags;
- }
- /**
- * Set the new content for the given slot role
- *
- * @param string $role A slot role name (such as "main")
- * @param Content $content
- */
- public function setContent( $role, Content $content ) {
- $this->ensureRoleAllowed( $role );
- $this->slotsUpdate->modifyContent( $role, $content );
- }
- /**
- * Set the new slot for the given slot role
- *
- * @param SlotRecord $slot
- */
- public function setSlot( SlotRecord $slot ) {
- $this->ensureRoleAllowed( $slot->getRole() );
- $this->slotsUpdate->modifySlot( $slot );
- }
- /**
- * Explicitly inherit a slot from some earlier revision.
- *
- * The primary use case for this is rollbacks, when slots are to be inherited from
- * the rollback target, overriding the content from the parent revision (which is the
- * revision being rolled back).
- *
- * This should typically not be used to inherit slots from the parent revision, which
- * happens implicitly. Using this method causes the given slot to be treated as "modified"
- * during revision creation, even if it has the same content as in the parent revision.
- *
- * @param SlotRecord $originalSlot A slot already existing in the database, to be inherited
- * by the new revision.
- */
- public function inheritSlot( SlotRecord $originalSlot ) {
- // NOTE: slots can be inherited even if the role is not "allowed" on the title.
- // NOTE: this slot is inherited from some other revision, but it's
- // a "modified" slot for the RevisionSlotsUpdate and DerivedPageDataUpdater,
- // since it's not implicitly inherited from the parent revision.
- $inheritedSlot = SlotRecord::newInherited( $originalSlot );
- $this->slotsUpdate->modifySlot( $inheritedSlot );
- }
- /**
- * Removes the slot with the given role.
- *
- * This discontinues the "stream" of slots with this role on the page,
- * preventing the new revision, and any subsequent revisions, from
- * inheriting the slot with this role.
- *
- * @param string $role A slot role name (but not "main")
- */
- public function removeSlot( $role ) {
- $this->ensureRoleNotRequired( $role );
- $this->slotsUpdate->removeSlot( $role );
- }
- /**
- * Returns the ID of an earlier revision that is being repeated or restored by this update.
- *
- * @return bool|int The original revision id, or false if no earlier revision is known to be
- * repeated or restored by this update.
- */
- public function getOriginalRevisionId() {
- return $this->originalRevId;
- }
- /**
- * Sets the ID of an earlier revision that is being repeated or restored by this update.
- * The new revision is expected to have the exact same content as the given original revision.
- * This is used with rollbacks and with dummy "null" revisions which are created to record
- * things like page moves.
- *
- * This value is passed to the PageContentSaveComplete and NewRevisionFromEditComplete hooks.
- *
- * @param int|bool $originalRevId The original revision id, or false if no earlier revision
- * is known to be repeated or restored by this update.
- */
- public function setOriginalRevisionId( $originalRevId ) {
- Assert::parameterType( 'integer|boolean', $originalRevId, '$originalRevId' );
- $this->originalRevId = $originalRevId;
- }
- /**
- * Returns the revision ID set by setUndidRevisionId(), indicating what revision is being
- * undone by this edit.
- *
- * @return int
- */
- public function getUndidRevisionId() {
- return $this->undidRevId;
- }
- /**
- * Sets the ID of revision that was undone by the present update.
- * This is used with the "undo" action, and is expected to hold the oldest revision ID
- * in case more then one revision is being undone.
- *
- * @param int $undidRevId
- */
- public function setUndidRevisionId( $undidRevId ) {
- Assert::parameterType( 'integer', $undidRevId, '$undidRevId' );
- $this->undidRevId = $undidRevId;
- }
- /**
- * Sets a tag to apply to this update.
- * Callers are responsible for permission checks,
- * using ChangeTags::canAddTagsAccompanyingChange.
- * @param string $tag
- */
- public function addTag( $tag ) {
- Assert::parameterType( 'string', $tag, '$tag' );
- $this->tags[] = trim( $tag );
- }
- /**
- * Sets tags to apply to this update.
- * Callers are responsible for permission checks,
- * using ChangeTags::canAddTagsAccompanyingChange.
- * @param string[] $tags
- */
- public function addTags( array $tags ) {
- Assert::parameterElementType( 'string', $tags, '$tags' );
- foreach ( $tags as $tag ) {
- $this->addTag( $tag );
- }
- }
- /**
- * Returns the list of tags set using the addTag() method.
- *
- * @return string[]
- */
- public function getExplicitTags() {
- return $this->tags;
- }
- /**
- * @param int $flags Bit mask: a bit mask of EDIT_XXX flags.
- * @return string[]
- */
- private function computeEffectiveTags( $flags ) {
- $tags = $this->tags;
- foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) {
- $old_content = $this->getParentContent( $role );
- $handler = $this->getContentHandler( $role );
- $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent();
- // TODO: MCR: Do this for all slots. Also add tags for removing roles!
- $tag = $handler->getChangeTag( $old_content, $content, $flags );
- // If there is no applicable tag, null is returned, so we need to check
- if ( $tag ) {
- $tags[] = $tag;
- }
- }
- // Check for undo tag
- if ( $this->undidRevId !== 0 && in_array( 'mw-undo', ChangeTags::getSoftwareTags() ) ) {
- $tags[] = 'mw-undo';
- }
- return array_unique( $tags );
- }
- /**
- * Returns the content of the given slot of the parent revision, with no audience checks applied.
- * If there is no parent revision or the slot is not defined, this returns null.
- *
- * @param string $role slot role name
- * @return Content|null
- */
- private function getParentContent( $role ) {
- $parent = $this->grabParentRevision();
- if ( $parent && $parent->hasSlot( $role ) ) {
- return $parent->getContent( $role, RevisionRecord::RAW );
- }
- return null;
- }
- /**
- * @param string $role slot role name
- * @return ContentHandler
- */
- private function getContentHandler( $role ) {
- // TODO: inject something like a ContentHandlerRegistry
- if ( $this->slotsUpdate->isModifiedSlot( $role ) ) {
- $slot = $this->slotsUpdate->getModifiedSlot( $role );
- } else {
- $parent = $this->grabParentRevision();
- if ( $parent ) {
- $slot = $parent->getSlot( $role, RevisionRecord::RAW );
- } else {
- throw new RevisionAccessException( 'No such slot: ' . $role );
- }
- }
- return ContentHandler::getForModelID( $slot->getModel() );
- }
- /**
- * @param int $flags Bit mask: a bit mask of EDIT_XXX flags.
- *
- * @return CommentStoreComment
- */
- private function makeAutoSummary( $flags ) {
- if ( !$this->useAutomaticEditSummaries || ( $flags & EDIT_AUTOSUMMARY ) === 0 ) {
- return CommentStoreComment::newUnsavedComment( '' );
- }
- // NOTE: this generates an auto-summary for SOME RANDOM changed slot!
- // TODO: combine auto-summaries for multiple slots!
- // XXX: this logic should not be in the storage layer!
- $roles = $this->slotsUpdate->getModifiedRoles();
- $role = reset( $roles );
- if ( $role === false ) {
- return CommentStoreComment::newUnsavedComment( '' );
- }
- $handler = $this->getContentHandler( $role );
- $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent();
- $old_content = $this->getParentContent( $role );
- $summary = $handler->getAutosummary( $old_content, $content, $flags );
- return CommentStoreComment::newUnsavedComment( $summary );
- }
- /**
- * Change an existing article or create a new article. Updates RC and all necessary caches,
- * optionally via the deferred update array. This does not check user permissions.
- *
- * It is guaranteed that saveRevision() will fail if the current revision of the page
- * changes after grabParentRevision() was called and before saveRevision() can insert
- * a new revision, as per the CAS mechanism described above.
- *
- * The caller is however responsible for calling hasEditConflict() to detect a
- * user-level edit conflict, and to adjust the content of the new revision accordingly,
- * e.g. by using a 3-way-merge.
- *
- * MCR migration note: this replaces WikiPage::doEditContent. Callers that change to using
- * saveRevision() now need to check the "minoredit" themselves before using EDIT_MINOR.
- *
- * @param CommentStoreComment $summary Edit summary
- * @param int $flags Bitfield:
- * EDIT_NEW
- * Create a new page, or fail with "edit-already-exists" if the page exists.
- * EDIT_UPDATE
- * Create a new revision, or fail with "edit-gone-missing" if the page does not exist.
- * EDIT_MINOR
- * Mark this revision as minor
- * EDIT_SUPPRESS_RC
- * Do not log the change in recentchanges
- * EDIT_FORCE_BOT
- * Mark the revision as automated ("bot edit")
- * EDIT_AUTOSUMMARY
- * Fill in blank summaries with generated text where possible
- * EDIT_INTERNAL
- * Signal that the page retrieve/save cycle happened entirely in this request.
- *
- * If neither EDIT_NEW nor EDIT_UPDATE is specified, the expected state is detected
- * automatically via grabParentRevision(). In this case, the "edit-already-exists" or
- * "edit-gone-missing" errors may still be triggered due to race conditions, if the page
- * was unexpectedly created or deleted while revision creation is in progress. This can be
- * viewed as part of the CAS mechanism described above.
- *
- * @return RevisionRecord|null The new revision, or null if no new revision was created due
- * to a failure or a null-edit. Use isUnchanged(), wasSuccessful() and getStatus()
- * to determine the outcome of the revision creation.
- *
- * @throws MWException
- * @throws RuntimeException
- */
- public function saveRevision( CommentStoreComment $summary, $flags = 0 ) {
- // Defend against mistakes caused by differences with the
- // signature of WikiPage::doEditContent.
- Assert::parameterType( 'integer', $flags, '$flags' );
- if ( $this->wasCommitted() ) {
- throw new RuntimeException( 'saveRevision() has already been called on this PageUpdater!' );
- }
- // Low-level sanity check
- if ( $this->getLinkTarget()->getText() === '' ) {
- throw new RuntimeException( 'Something is trying to edit an article with an empty title' );
- }
- // NOTE: slots can be inherited even if the role is not "allowed" on the title.
- $status = Status::newGood();
- $this->checkAllRolesAllowed(
- $this->slotsUpdate->getModifiedRoles(),
- $status
- );
- $this->checkNoRolesRequired(
- $this->slotsUpdate->getRemovedRoles(),
- $status
- );
- if ( !$status->isOK() ) {
- return null;
- }
- // Make sure the given content is allowed in the respective slots of this page
- foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) {
- $slot = $this->slotsUpdate->getModifiedSlot( $role );
- $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
- if ( !$roleHandler->isAllowedModel( $slot->getModel(), $this->getTitle() ) ) {
- $contentHandler = ContentHandler::getForModelID( $slot->getModel() );
- $this->status = Status::newFatal( 'content-not-allowed-here',
- ContentHandler::getLocalizedName( $contentHandler->getModelID() ),
- $this->getTitle()->getPrefixedText(),
- wfMessage( $roleHandler->getNameMessageKey() )
- // TODO: defer message lookup to caller
- );
- return null;
- }
- }
- // Load the data from the master database if needed. Needed to check flags.
- // NOTE: This grabs the parent revision as the CAS token, if grabParentRevision
- // wasn't called yet. If the page is modified by another process before we are done with
- // it, this method must fail (with status 'edit-conflict')!
- // NOTE: The parent revision may be different from $this->originalRevisionId.
- $this->grabParentRevision();
- $flags = $this->checkFlags( $flags );
- // Avoid statsd noise and wasted cycles check the edit stash (T136678)
- if ( ( $flags & EDIT_INTERNAL ) || ( $flags & EDIT_FORCE_BOT ) ) {
- $useStashed = false;
- } else {
- $useStashed = $this->ajaxEditStash;
- }
- // TODO: use this only for the legacy hook, and only if something uses the legacy hook
- $wikiPage = $this->getWikiPage();
- $user = $this->user;
- // Prepare the update. This performs PST and generates the canonical ParserOutput.
- $this->derivedDataUpdater->prepareContent(
- $this->user,
- $this->slotsUpdate,
- $useStashed
- );
- // TODO: don't force initialization here!
- // This is a hack to work around the fact that late initialization of the ParserOutput
- // causes ApiFlowEditHeaderTest::testCache to fail. Whether that failure indicates an
- // actual problem, or is just an issue with the test setup, remains to be determined
- // [dk, 2018-03].
- // Anomie said in 2018-03:
- /*
- I suspect that what's breaking is this:
- The old version of WikiPage::doEditContent() called prepareContentForEdit() which
- generated the ParserOutput right then, so when doEditUpdates() gets called from the
- DeferredUpdate scheduled by WikiPage::doCreate() there's no need to parse. I note
- there's a comment there that says "Get the pre-save transform content and final
- parser output".
- The new version of WikiPage::doEditContent() makes a PageUpdater and calls its
- saveRevision(), which calls DerivedPageDataUpdater::prepareContent() and
- PageUpdater::doCreate() without ever having to actually generate a ParserOutput.
- Thus, when DerivedPageDataUpdater::doUpdates() is called from the DeferredUpdate
- scheduled by PageUpdater::doCreate(), it does find that it needs to parse at that point.
- And the order of operations in that Flow test is presumably:
- - Create a page with a call to WikiPage::doEditContent(), in a way that somehow avoids
- processing the DeferredUpdate.
- - Set up the "no set!" mock cache in Flow\Tests\Api\ApiTestCase::expectCacheInvalidate()
- - Then, during the course of doing that test, a $db->commit() results in the
- DeferredUpdates being run.
- */
- $this->derivedDataUpdater->getCanonicalParserOutput();
- $mainContent = $this->derivedDataUpdater->getSlots()->getContent( SlotRecord::MAIN );
- // Trigger pre-save hook (using provided edit summary)
- $hookStatus = Status::newGood( [] );
- // TODO: replace legacy hook!
- // TODO: avoid pass-by-reference, see T193950
- $hook_args = [ &$wikiPage, &$user, &$mainContent, &$summary,
- $flags & EDIT_MINOR, null, null, &$flags, &$hookStatus ];
- // Check if the hook rejected the attempted save
- if ( !Hooks::run( 'PageContentSave', $hook_args ) ) {
- if ( $hookStatus->isOK() ) {
- // Hook returned false but didn't call fatal(); use generic message
- $hookStatus->fatal( 'edit-hook-aborted' );
- }
- $this->status = $hookStatus;
- return null;
- }
- // Provide autosummaries if one is not provided and autosummaries are enabled
- // XXX: $summary == null seems logical, but the empty string may actually come from the user
- // XXX: Move this logic out of the storage layer! It does not belong here! Use a callback?
- if ( $summary->text === '' && $summary->data === null ) {
- $summary = $this->makeAutoSummary( $flags );
- }
- // Actually create the revision and create/update the page.
- // Do NOT yet set $this->status!
- if ( $flags & EDIT_UPDATE ) {
- $status = $this->doModify( $summary, $this->user, $flags );
- } else {
- $status = $this->doCreate( $summary, $this->user, $flags );
- }
- // Promote user to any groups they meet the criteria for
- DeferredUpdates::addCallableUpdate( function () use ( $user ) {
- $user->addAutopromoteOnceGroups( 'onEdit' );
- $user->addAutopromoteOnceGroups( 'onView' ); // b/c
- } );
- // NOTE: set $this->status only after all hooks have been called,
- // so wasCommitted doesn't return true wehn called indirectly from a hook handler!
- $this->status = $status;
- // TODO: replace bad status with Exceptions!
- return ( $this->status && $this->status->isOK() )
- ? $this->status->value['revision-record']
- : null;
- }
- /**
- * Whether saveRevision() has been called on this instance
- *
- * @return bool
- */
- public function wasCommitted() {
- return $this->status !== null;
- }
- /**
- * The Status object indicating whether saveRevision() was successful, or null if
- * saveRevision() was not yet called on this instance.
- *
- * @note This is here for compatibility with WikiPage::doEditContent. It may be deprecated
- * soon.
- *
- * Possible status errors:
- * edit-hook-aborted: The ArticleSave hook aborted the update but didn't
- * set the fatal flag of $status.
- * edit-gone-missing: In update mode, but the article didn't exist.
- * edit-conflict: In update mode, the article changed unexpectedly.
- * edit-no-change: Warning that the text was the same as before.
- * edit-already-exists: In creation mode, but the article already exists.
- *
- * Extensions may define additional errors.
- *
- * $return->value will contain an associative array with members as follows:
- * new: Boolean indicating if the function attempted to create a new article.
- * revision: The revision object for the inserted revision, or null.
- *
- * @return null|Status
- */
- public function getStatus() {
- return $this->status;
- }
- /**
- * Whether saveRevision() completed successfully
- *
- * @return bool
- */
- public function wasSuccessful() {
- return $this->status && $this->status->isOK();
- }
- /**
- * Whether saveRevision() was called and created a new page.
- *
- * @return bool
- */
- public function isNew() {
- return $this->status && $this->status->isOK() && $this->status->value['new'];
- }
- /**
- * Whether saveRevision() did not create a revision because the content didn't change
- * (null-edit). Whether the content changed or not is determined by
- * DerivedPageDataUpdater::isChange().
- *
- * @return bool
- */
- public function isUnchanged() {
- return $this->status
- && $this->status->isOK()
- && $this->status->value['revision-record'] === null;
- }
- /**
- * The new revision created by saveRevision(), or null if saveRevision() has not yet been
- * called, failed, or did not create a new revision because the content did not change.
- *
- * @return RevisionRecord|null
- */
- public function getNewRevision() {
- return ( $this->status && $this->status->isOK() )
- ? $this->status->value['revision-record']
- : null;
- }
- /**
- * Constructs a MutableRevisionRecord based on the Content prepared by the
- * DerivedPageDataUpdater. This takes care of inheriting slots, updating slots
- * with PST applied, and removing discontinued slots.
- *
- * This calls Content::prepareSave() to verify that the slot content can be saved.
- * The $status parameter is updated with any errors or warnings found by Content::prepareSave().
- *
- * @param CommentStoreComment $comment
- * @param User $user
- * @param int $flags
- * @param Status $status
- *
- * @return MutableRevisionRecord
- */
- private function makeNewRevision(
- CommentStoreComment $comment,
- User $user,
- $flags,
- Status $status
- ) {
- $wikiPage = $this->getWikiPage();
- $title = $this->getTitle();
- $parent = $this->grabParentRevision();
- // XXX: we expect to get a MutableRevisionRecord here, but that's a bit brittle!
- // TODO: introduce something like an UnsavedRevisionFactory service instead!
- /** @var MutableRevisionRecord $rev */
- $rev = $this->derivedDataUpdater->getRevision();
- '@phan-var MutableRevisionRecord $rev';
- $rev->setPageId( $title->getArticleID() );
- if ( $parent ) {
- $oldid = $parent->getId();
- $rev->setParentId( $oldid );
- } else {
- $oldid = 0;
- }
- $rev->setComment( $comment );
- $rev->setUser( $user );
- $rev->setMinorEdit( ( $flags & EDIT_MINOR ) > 0 );
- foreach ( $rev->getSlots()->getSlots() as $slot ) {
- $content = $slot->getContent();
- // XXX: We may push this up to the "edit controller" level, see T192777.
- // XXX: prepareSave() and isValid() could live in SlotRoleHandler
- // XXX: PrepareSave should not take a WikiPage!
- $prepStatus = $content->prepareSave( $wikiPage, $flags, $oldid, $user );
- // TODO: MCR: record which problem arose in which slot.
- $status->merge( $prepStatus );
- }
- $this->checkAllRequiredRoles(
- $rev->getSlotRoles(),
- $status
- );
- return $rev;
- }
- /**
- * @param CommentStoreComment $summary The edit summary
- * @param User $user The revision's author
- * @param int $flags EXIT_XXX constants
- *
- * @throws MWException
- * @return Status
- */
- private function doModify( CommentStoreComment $summary, User $user, $flags ) {
- $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
- // Update article, but only if changed.
- $status = Status::newGood( [ 'new' => false, 'revision' => null, 'revision-record' => null ] );
- $oldRev = $this->grabParentRevision();
- $oldid = $oldRev ? $oldRev->getId() : 0;
- if ( !$oldRev ) {
- // Article gone missing
- $status->fatal( 'edit-gone-missing' );
- return $status;
- }
- $newRevisionRecord = $this->makeNewRevision(
- $summary,
- $user,
- $flags,
- $status
- );
- if ( !$status->isOK() ) {
- return $status;
- }
- $now = $newRevisionRecord->getTimestamp();
- // XXX: we may want a flag that allows a null revision to be forced!
- $changed = $this->derivedDataUpdater->isChange();
- $dbw = $this->getDBConnectionRef( DB_MASTER );
- if ( $changed ) {
- $dbw->startAtomic( __METHOD__ );
- // Get the latest page_latest value while locking it.
- // Do a CAS style check to see if it's the same as when this method
- // started. If it changed then bail out before touching the DB.
- $latestNow = $wikiPage->lockAndGetLatest(); // TODO: move to storage service, pass DB
- if ( $latestNow != $oldid ) {
- // We don't need to roll back, since we did not modify the database yet.
- // XXX: Or do we want to rollback, any transaction started by calling
- // code will fail? If we want that, we should probably throw an exception.
- $dbw->endAtomic( __METHOD__ );
- // Page updated or deleted in the mean time
- $status->fatal( 'edit-conflict' );
- return $status;
- }
- // At this point we are now comitted to returning an OK
- // status unless some DB query error or other exception comes up.
- // This way callers don't have to call rollback() if $status is bad
- // unless they actually try to catch exceptions (which is rare).
- // Save revision content and meta-data
- $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw );
- $newLegacyRevision = new Revision( $newRevisionRecord );
- // Update page_latest and friends to reflect the new revision
- // TODO: move to storage service
- $wasRedirect = $this->derivedDataUpdater->wasRedirect();
- if ( !$wikiPage->updateRevisionOn( $dbw, $newLegacyRevision, null, $wasRedirect ) ) {
- throw new PageUpdateException( "Failed to update page row to use new revision." );
- }
- // TODO: replace legacy hook!
- $tags = $this->computeEffectiveTags( $flags );
- Hooks::run(
- 'NewRevisionFromEditComplete',
- [ $wikiPage, $newLegacyRevision, $this->getOriginalRevisionId(), $user, &$tags ]
- );
- // Update recentchanges
- if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
- // Add RC row to the DB
- RecentChange::notifyEdit(
- $now,
- $this->getTitle(),
- $newRevisionRecord->isMinor(),
- $user,
- $summary->text, // TODO: pass object when that becomes possible
- $oldid,
- $newRevisionRecord->getTimestamp(),
- ( $flags & EDIT_FORCE_BOT ) > 0,
- '',
- $oldRev->getSize(),
- $newRevisionRecord->getSize(),
- $newRevisionRecord->getId(),
- $this->rcPatrolStatus,
- $tags
- );
- }
- $user->incEditCount();
- $dbw->endAtomic( __METHOD__ );
- // Return the new revision to the caller
- $status->value['revision-record'] = $newRevisionRecord;
- // TODO: globally replace usages of 'revision' with getNewRevision()
- $status->value['revision'] = $newLegacyRevision;
- } else {
- // T34948: revision ID must be set to page {{REVISIONID}} and
- // related variables correctly. Likewise for {{REVISIONUSER}} (T135261).
- // Since we don't insert a new revision into the database, the least
- // error-prone way is to reuse given old revision.
- $newRevisionRecord = $oldRev;
- $status->warning( 'edit-no-change' );
- // Update page_touched as updateRevisionOn() was not called.
- // Other cache updates are managed in WikiPage::onArticleEdit()
- // via WikiPage::doEditUpdates().
- $this->getTitle()->invalidateCache( $now );
- }
- // Do secondary updates once the main changes have been committed...
- // NOTE: the updates have to be processed before sending the response to the client
- // (DeferredUpdates::PRESEND), otherwise the client may already be following the
- // HTTP redirect to the standard view before dervide data has been created - most
- // importantly, before the parser cache has been updated. This would cause the
- // content to be parsed a second time, or may cause stale content to be shown.
- DeferredUpdates::addUpdate(
- $this->getAtomicSectionUpdate(
- $dbw,
- $wikiPage,
- $newRevisionRecord,
- $user,
- $summary,
- $flags,
- $status,
- [ 'changed' => $changed, ]
- ),
- DeferredUpdates::PRESEND
- );
- return $status;
- }
- /**
- * @param CommentStoreComment $summary The edit summary
- * @param User $user The revision's author
- * @param int $flags EXIT_XXX constants
- *
- * @throws DBUnexpectedError
- * @throws MWException
- * @return Status
- */
- private function doCreate( CommentStoreComment $summary, User $user, $flags ) {
- $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
- if ( !$this->derivedDataUpdater->getSlots()->hasSlot( SlotRecord::MAIN ) ) {
- throw new PageUpdateException( 'Must provide a main slot when creating a page!' );
- }
- $status = Status::newGood( [ 'new' => true, 'revision' => null, 'revision-record' => null ] );
- $newRevisionRecord = $this->makeNewRevision(
- $summary,
- $user,
- $flags,
- $status
- );
- if ( !$status->isOK() ) {
- return $status;
- }
- $now = $newRevisionRecord->getTimestamp();
- $dbw = $this->getDBConnectionRef( DB_MASTER );
- $dbw->startAtomic( __METHOD__ );
- // Add the page record unless one already exists for the title
- // TODO: move to storage service
- $newid = $wikiPage->insertOn( $dbw );
- if ( $newid === false ) {
- $dbw->endAtomic( __METHOD__ );
- $status->fatal( 'edit-already-exists' );
- return $status;
- }
- // At this point we are now comitted to returning an OK
- // status unless some DB query error or other exception comes up.
- // This way callers don't have to call rollback() if $status is bad
- // unless they actually try to catch exceptions (which is rare).
- $newRevisionRecord->setPageId( $newid );
- // Save the revision text...
- $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw );
- $newLegacyRevision = new Revision( $newRevisionRecord );
- // Update the page record with revision data
- // TODO: move to storage service
- if ( !$wikiPage->updateRevisionOn( $dbw, $newLegacyRevision, 0 ) ) {
- throw new PageUpdateException( "Failed to update page row to use new revision." );
- }
- // TODO: replace legacy hook!
- $tags = $this->computeEffectiveTags( $flags );
- Hooks::run(
- 'NewRevisionFromEditComplete',
- [ $wikiPage, $newLegacyRevision, false, $user, &$tags ]
- );
- // Update recentchanges
- if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
- // Add RC row to the DB
- RecentChange::notifyNew(
- $now,
- $this->getTitle(),
- $newRevisionRecord->isMinor(),
- $user,
- $summary->text, // TODO: pass object when that becomes possible
- ( $flags & EDIT_FORCE_BOT ) > 0,
- '',
- $newRevisionRecord->getSize(),
- $newRevisionRecord->getId(),
- $this->rcPatrolStatus,
- $tags
- );
- }
- $user->incEditCount();
- if ( $this->usePageCreationLog ) {
- // Log the page creation
- // @TODO: Do we want a 'recreate' action?
- $logEntry = new ManualLogEntry( 'create', 'create' );
- $logEntry->setPerformer( $user );
- $logEntry->setTarget( $this->getTitle() );
- $logEntry->setComment( $summary->text );
- $logEntry->setTimestamp( $now );
- $logEntry->setAssociatedRevId( $newRevisionRecord->getId() );
- $logEntry->insert();
- // Note that we don't publish page creation events to recentchanges
- // (i.e. $logEntry->publish()) since this would create duplicate entries,
- // one for the edit and one for the page creation.
- }
- $dbw->endAtomic( __METHOD__ );
- // Return the new revision to the caller
- // TODO: globally replace usages of 'revision' with getNewRevision()
- $status->value['revision'] = $newLegacyRevision;
- $status->value['revision-record'] = $newRevisionRecord;
- // Do secondary updates once the main changes have been committed...
- DeferredUpdates::addUpdate(
- $this->getAtomicSectionUpdate(
- $dbw,
- $wikiPage,
- $newRevisionRecord,
- $user,
- $summary,
- $flags,
- $status,
- [ 'created' => true ]
- ),
- DeferredUpdates::PRESEND
- );
- return $status;
- }
- private function getAtomicSectionUpdate(
- IDatabase $dbw,
- WikiPage $wikiPage,
- RevisionRecord $newRevisionRecord,
- User $user,
- CommentStoreComment $summary,
- $flags,
- Status $status,
- $hints = []
- ) {
- return new AtomicSectionUpdate(
- $dbw,
- __METHOD__,
- function () use (
- $wikiPage, $newRevisionRecord, $user,
- $summary, $flags, $status, $hints
- ) {
- // set debug data
- $hints['causeAction'] = 'edit-page';
- $hints['causeAgent'] = $user->getName();
- $newLegacyRevision = new Revision( $newRevisionRecord );
- $mainContent = $newRevisionRecord->getContent( SlotRecord::MAIN, RevisionRecord::RAW );
- // Update links tables, site stats, etc.
- $this->derivedDataUpdater->prepareUpdate( $newRevisionRecord, $hints );
- $this->derivedDataUpdater->doUpdates();
- // TODO: replace legacy hook!
- // TODO: avoid pass-by-reference, see T193950
- if ( $hints['created'] ?? false ) {
- // Trigger post-create hook
- $params = [ &$wikiPage, &$user, $mainContent, $summary->text,
- $flags & EDIT_MINOR, null, null, &$flags, $newLegacyRevision ];
- Hooks::run( 'PageContentInsertComplete', $params );
- }
- // Trigger post-save hook
- $params = [ &$wikiPage, &$user, $mainContent, $summary->text,
- $flags & EDIT_MINOR, null, null, &$flags, $newLegacyRevision,
- &$status, $this->getOriginalRevisionId(), $this->undidRevId ];
- Hooks::run( 'PageContentSaveComplete', $params );
- }
- );
- }
- /**
- * @return string[] Slots required for this page update, as a list of role names.
- */
- private function getRequiredSlotRoles() {
- return $this->slotRoleRegistry->getRequiredRoles( $this->getTitle() );
- }
- /**
- * @return string[] Slots allowed for this page update, as a list of role names.
- */
- private function getAllowedSlotRoles() {
- return $this->slotRoleRegistry->getAllowedRoles( $this->getTitle() );
- }
- private function ensureRoleAllowed( $role ) {
- $allowedRoles = $this->getAllowedSlotRoles();
- if ( !in_array( $role, $allowedRoles ) ) {
- throw new PageUpdateException( "Slot role `$role` is not allowed." );
- }
- }
- private function ensureRoleNotRequired( $role ) {
- $requiredRoles = $this->getRequiredSlotRoles();
- if ( in_array( $role, $requiredRoles ) ) {
- throw new PageUpdateException( "Slot role `$role` is required." );
- }
- }
- private function checkAllRolesAllowed( array $roles, Status $status ) {
- $allowedRoles = $this->getAllowedSlotRoles();
- $forbidden = array_diff( $roles, $allowedRoles );
- if ( !empty( $forbidden ) ) {
- $status->error(
- 'edit-slots-cannot-add',
- count( $forbidden ),
- implode( ', ', $forbidden )
- );
- }
- }
- private function checkNoRolesRequired( array $roles, Status $status ) {
- $requiredRoles = $this->getRequiredSlotRoles();
- $needed = array_diff( $roles, $requiredRoles );
- if ( !empty( $needed ) ) {
- $status->error(
- 'edit-slots-cannot-remove',
- count( $needed ),
- implode( ', ', $needed )
- );
- }
- }
- private function checkAllRequiredRoles( array $roles, Status $status ) {
- $requiredRoles = $this->getRequiredSlotRoles();
- $missing = array_diff( $requiredRoles, $roles );
- if ( !empty( $missing ) ) {
- $status->error(
- 'edit-slots-missing',
- count( $missing ),
- implode( ', ', $missing )
- );
- }
- }
- }
|