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