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