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