Merge "Fix 'Tags' padding to keep it farther from the edge and document the source...
[lhc/web/wiklou.git] / includes / Storage / PageUpdater.php
1 <?php
2 /**
3 * Controller-like object for creating and updating pages by creating new revisions.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 *
22 * @author Daniel Kinzler
23 */
24
25 namespace MediaWiki\Storage;
26
27 use AtomicSectionUpdate;
28 use ChangeTags;
29 use CommentStoreComment;
30 use Content;
31 use ContentHandler;
32 use DeferredUpdates;
33 use Hooks;
34 use InvalidArgumentException;
35 use LogicException;
36 use ManualLogEntry;
37 use MediaWiki\Linker\LinkTarget;
38 use MWException;
39 use RecentChange;
40 use Revision;
41 use RuntimeException;
42 use Status;
43 use Title;
44 use User;
45 use Wikimedia\Assert\Assert;
46 use Wikimedia\Rdbms\DBConnRef;
47 use Wikimedia\Rdbms\DBUnexpectedError;
48 use Wikimedia\Rdbms\LoadBalancer;
49 use WikiPage;
50
51 /**
52 * Controller-like object for creating and updating pages by creating new revisions.
53 *
54 * PageUpdater instances provide compare-and-swap (CAS) protection against concurrent updates
55 * between the time grabParentRevision() is called and saveRevision() inserts a new revision.
56 * This allows application logic to safely perform edit conflict resolution using the parent
57 * revision's content.
58 *
59 * @see docs/pageupdater.txt for more information.
60 *
61 * MCR migration note: this replaces the relevant methods in WikiPage.
62 *
63 * @since 1.32
64 * @ingroup Page
65 */
66 class PageUpdater {
67
68 /**
69 * @var User
70 */
71 private $user;
72
73 /**
74 * @var WikiPage
75 */
76 private $wikiPage;
77
78 /**
79 * @var DerivedPageDataUpdater
80 */
81 private $derivedDataUpdater;
82
83 /**
84 * @var LoadBalancer
85 */
86 private $loadBalancer;
87
88 /**
89 * @var RevisionStore
90 */
91 private $revisionStore;
92
93 /**
94 * @var boolean see $wgUseAutomaticEditSummaries
95 * @see $wgUseAutomaticEditSummaries
96 */
97 private $useAutomaticEditSummaries = true;
98
99 /**
100 * @var int the RC patrol status the new revision should be marked with.
101 */
102 private $rcPatrolStatus = RecentChange::PRC_UNPATROLLED;
103
104 /**
105 * @var bool whether to create a log entry for new page creations.
106 */
107 private $usePageCreationLog = true;
108
109 /**
110 * @var boolean see $wgAjaxEditStash
111 */
112 private $ajaxEditStash = true;
113
114 /**
115 * @var bool|int
116 */
117 private $originalRevId = false;
118
119 /**
120 * @var array
121 */
122 private $tags = [];
123
124 /**
125 * @var int
126 */
127 private $undidRevId = 0;
128
129 /**
130 * @var RevisionSlotsUpdate
131 */
132 private $slotsUpdate;
133
134 /**
135 * @var Status|null
136 */
137 private $status = null;
138
139 /**
140 * @param User $user
141 * @param WikiPage $wikiPage
142 * @param DerivedPageDataUpdater $derivedDataUpdater
143 * @param LoadBalancer $loadBalancer
144 * @param RevisionStore $revisionStore
145 */
146 public function __construct(
147 User $user,
148 WikiPage $wikiPage,
149 DerivedPageDataUpdater $derivedDataUpdater,
150 LoadBalancer $loadBalancer,
151 RevisionStore $revisionStore
152 ) {
153 $this->user = $user;
154 $this->wikiPage = $wikiPage;
155 $this->derivedDataUpdater = $derivedDataUpdater;
156
157 $this->loadBalancer = $loadBalancer;
158 $this->revisionStore = $revisionStore;
159
160 $this->slotsUpdate = new RevisionSlotsUpdate();
161 }
162
163 /**
164 * Can be used to enable or disable automatic summaries that are applied to certain kinds of
165 * changes, like completely blanking a page.
166 *
167 * @param bool $useAutomaticEditSummaries
168 * @see $wgUseAutomaticEditSummaries
169 */
170 public function setUseAutomaticEditSummaries( $useAutomaticEditSummaries ) {
171 $this->useAutomaticEditSummaries = $useAutomaticEditSummaries;
172 }
173
174 /**
175 * Sets the "patrolled" status of the edit.
176 * Callers should check the "patrol" and "autopatrol" permissions as appropriate.
177 *
178 * @see $wgUseRCPatrol
179 * @see $wgUseNPPatrol
180 *
181 * @param int $status RC patrol status, e.g. RecentChange::PRC_AUTOPATROLLED.
182 */
183 public function setRcPatrolStatus( $status ) {
184 $this->rcPatrolStatus = $status;
185 }
186
187 /**
188 * Whether to create a log entry for new page creations.
189 *
190 * @see $wgPageCreationLog
191 *
192 * @param bool $use
193 */
194 public function setUsePageCreationLog( $use ) {
195 $this->usePageCreationLog = $use;
196 }
197
198 /**
199 * @param bool $ajaxEditStash
200 * @see $wgAjaxEditStash
201 */
202 public function setAjaxEditStash( $ajaxEditStash ) {
203 $this->ajaxEditStash = $ajaxEditStash;
204 }
205
206 private function getWikiId() {
207 return false; // TODO: get from RevisionStore!
208 }
209
210 /**
211 * @param int $mode DB_MASTER or DB_REPLICA
212 *
213 * @return DBConnRef
214 */
215 private function getDBConnectionRef( $mode ) {
216 return $this->loadBalancer->getConnectionRef( $mode, [], $this->getWikiId() );
217 }
218
219 /**
220 * @return LinkTarget
221 */
222 private function getLinkTarget() {
223 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
224 return $this->wikiPage->getTitle();
225 }
226
227 /**
228 * @return Title
229 */
230 private function getTitle() {
231 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
232 return $this->wikiPage->getTitle();
233 }
234
235 /**
236 * @return WikiPage
237 */
238 private function getWikiPage() {
239 // NOTE: eventually, we won't get a WikiPage passed into the constructor any more
240 return $this->wikiPage;
241 }
242
243 /**
244 * Checks whether this update conflicts with another update performed between the client
245 * loading data to prepare an edit, and the client committing the edit. This is intended to
246 * detect user level "edit conflict" when the latest revision known to the client
247 * is no longer the current revision when processing the update.
248 *
249 * An update expected to create a new page can be checked by setting $expectedParentRevision = 0.
250 * Such an update is considered to have a conflict if a current revision exists (that is,
251 * the page was created since the edit was initiated on the client).
252 *
253 * This method returning true indicates to calling code that edit conflict resolution should
254 * be applied before saving any data. It does not prevent the update from being performed, and
255 * it should not be confused with a "late" conflict indicated by the "edit-conflict" status.
256 * A "late" conflict is a CAS failure caused by an update being performed concurrently between
257 * the time grabParentRevision() was called and the time saveRevision() trying to insert the
258 * new revision.
259 *
260 * @note A user level edit conflict is not the same as the "edit-conflict" status triggered by
261 * a CAS failure. Calling this method establishes the CAS token, it does not check against it:
262 * This method calls grabParentRevision(), and thus causes the expected parent revision
263 * for the update to be fixed to the page's current revision at this point in time.
264 * It acts as a compare-and-swap (CAS) token in that it is guaranteed that saveRevision()
265 * will fail with the "edit-conflict" status if the current revision of the page changes after
266 * hasEditConflict() (or grabParentRevision()) was called and before saveRevision() could insert
267 * a new revision.
268 *
269 * @see grabParentRevision()
270 *
271 * @param int $expectedParentRevision The ID of the revision the client expects to be the
272 * current one. Use 0 to indicate that the page is expected to not yet exist.
273 *
274 * @return bool
275 */
276 public function hasEditConflict( $expectedParentRevision ) {
277 $parent = $this->grabParentRevision();
278 $parentId = $parent ? $parent->getId() : 0;
279
280 return $parentId !== $expectedParentRevision;
281 }
282
283 /**
284 * Returns the revision that was the page's current revision when grabParentRevision()
285 * was first called. This revision is the expected parent revision of the update, and will be
286 * recorded as the new revision's parent revision (unless no new revision is created because
287 * the content was not changed).
288 *
289 * This method MUST not be called after saveRevision() was called!
290 *
291 * The current revision determined by the first call to this methods effectively acts a
292 * compare-and-swap (CAS) token which is checked by saveRevision(), which fails if any
293 * concurrent updates created a new revision.
294 *
295 * Application code should call this method before applying transformations to the new
296 * content that depend on the parent revision, e.g. adding/replacing sections, or resolving
297 * conflicts via a 3-way merge. This protects against race conditions triggered by concurrent
298 * updates.
299 *
300 * @see DerivedPageDataUpdater::grabCurrentRevision()
301 *
302 * @note The expected parent revision is not to be confused with the logical base revision.
303 * The base revision is specified by the client, the parent revision is determined from the
304 * database. If base revision and parent revision are not the same, the updates is considered
305 * to require edit conflict resolution.
306 *
307 * @throws LogicException if called after saveRevision().
308 * @return RevisionRecord|null the parent revision, or null of the page does not yet exist.
309 */
310 public function grabParentRevision() {
311 return $this->derivedDataUpdater->grabCurrentRevision();
312 }
313
314 /**
315 * @return string
316 */
317 private function getTimestampNow() {
318 // TODO: allow an override to be injected for testing
319 return wfTimestampNow();
320 }
321
322 /**
323 * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
324 *
325 * @param int $flags
326 * @return int Updated $flags
327 */
328 private function checkFlags( $flags ) {
329 if ( !( $flags & EDIT_NEW ) && !( $flags & EDIT_UPDATE ) ) {
330 $flags |= ( $this->derivedDataUpdater->pageExisted() ) ? EDIT_UPDATE : EDIT_NEW;
331 }
332
333 return $flags;
334 }
335
336 /**
337 * Set the new content for the given slot role
338 *
339 * @param string $role A slot role name (such as "main")
340 * @param Content $content
341 */
342 public function setContent( $role, Content $content ) {
343 // TODO: MCR: check the role and the content's model against the list of supported
344 // roles, see T194046.
345
346 $this->slotsUpdate->modifyContent( $role, $content );
347 }
348
349 /**
350 * Explicitly inherit a slot from some earlier revision.
351 *
352 * The primary use case for this is rollbacks, when slots are to be inherited from
353 * the rollback target, overriding the content from the parent revision (which is the
354 * revision being rolled back).
355 *
356 * This should typically not be used to inherit slots from the parent revision, which
357 * happens implicitly. Using this method causes the given slot to be treated as "modified"
358 * during revision creation, even if it has the same content as in the parent revision.
359 *
360 * @param SlotRecord $originalSlot A slot already existing in the database, to be inherited
361 * by the new revision.
362 */
363 public function inheritSlot( SlotRecord $originalSlot ) {
364 // NOTE: this slot is inherited from some other revision, but it's
365 // a "modified" slot for the RevisionSlotsUpdate and DerivedPageDataUpdater,
366 // since it's not implicitly inherited from the parent revision.
367 $inheritedSlot = SlotRecord::newInherited( $originalSlot );
368 $this->slotsUpdate->modifySlot( $inheritedSlot );
369 }
370
371 /**
372 * Removes the slot with the given role.
373 *
374 * This discontinues the "stream" of slots with this role on the page,
375 * preventing the new revision, and any subsequent revisions, from
376 * inheriting the slot with this role.
377 *
378 * @param string $role A slot role name (but not "main")
379 */
380 public function removeSlot( $role ) {
381 if ( $role === 'main' ) {
382 throw new InvalidArgumentException( 'Cannot remove the main slot!' );
383 }
384
385 $this->slotsUpdate->removeSlot( $role );
386 }
387
388 /**
389 * Returns the ID of an earlier revision that is being repeated or restored by this update.
390 *
391 * @return bool|int The original revision id, or false if no earlier revision is known to be
392 * repeated or restored by this update.
393 */
394 public function getOriginalRevisionId() {
395 return $this->originalRevId;
396 }
397
398 /**
399 * Sets the ID of an earlier revision that is being repeated or restored by this update.
400 * The new revision is expected to have the exact same content as the given original revision.
401 * This is used with rollbacks and with dummy "null" revisions which are created to record
402 * things like page moves.
403 *
404 * This value is passed to the PageContentSaveComplete and NewRevisionFromEditComplete hooks.
405 *
406 * @param int|bool $originalRevId The original revision id, or false if no earlier revision
407 * is known to be repeated or restored by this update.
408 */
409 public function setOriginalRevisionId( $originalRevId ) {
410 Assert::parameterType( 'integer|boolean', $originalRevId, '$originalRevId' );
411 $this->originalRevId = $originalRevId;
412 }
413
414 /**
415 * Returns the revision ID set by setUndidRevisionId(), indicating what revision is being
416 * undone by this edit.
417 *
418 * @return int
419 */
420 public function getUndidRevisionId() {
421 return $this->undidRevId;
422 }
423
424 /**
425 * Sets the ID of revision that was undone by the present update.
426 * This is used with the "undo" action, and is expected to hold the oldest revision ID
427 * in case more then one revision is being undone.
428 *
429 * @param int $undidRevId
430 */
431 public function setUndidRevisionId( $undidRevId ) {
432 Assert::parameterType( 'integer', $undidRevId, '$undidRevId' );
433 $this->undidRevId = $undidRevId;
434 }
435
436 /**
437 * Sets a tag to apply to this update.
438 * Callers are responsible for permission checks,
439 * using ChangeTags::canAddTagsAccompanyingChange.
440 * @param string $tag
441 */
442 public function addTag( $tag ) {
443 Assert::parameterType( 'string', $tag, '$tag' );
444 $this->tags[] = trim( $tag );
445 }
446
447 /**
448 * Sets tags to apply to this update.
449 * Callers are responsible for permission checks,
450 * using ChangeTags::canAddTagsAccompanyingChange.
451 * @param string[] $tags
452 */
453 public function addTags( array $tags ) {
454 Assert::parameterElementType( 'string', $tags, '$tags' );
455 foreach ( $tags as $tag ) {
456 $this->addTag( $tag );
457 }
458 }
459
460 /**
461 * Returns the list of tags set using the addTag() method.
462 *
463 * @return string[]
464 */
465 public function getExplicitTags() {
466 return $this->tags;
467 }
468
469 /**
470 * @param int $flags Bit mask: a bit mask of EDIT_XXX flags.
471 * @return string[]
472 */
473 private function computeEffectiveTags( $flags ) {
474 $tags = $this->tags;
475
476 foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) {
477 $old_content = $this->getParentContent( $role );
478
479 $handler = $this->getContentHandler( $role );
480 $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent();
481
482 // TODO: MCR: Do this for all slots. Also add tags for removing roles!
483 $tag = $handler->getChangeTag( $old_content, $content, $flags );
484 // If there is no applicable tag, null is returned, so we need to check
485 if ( $tag ) {
486 $tags[] = $tag;
487 }
488 }
489
490 // Check for undo tag
491 if ( $this->undidRevId !== 0 && in_array( 'mw-undo', ChangeTags::getSoftwareTags() ) ) {
492 $tags[] = 'mw-undo';
493 }
494
495 return array_unique( $tags );
496 }
497
498 /**
499 * Returns the content of the given slot of the parent revision, with no audience checks applied.
500 * If there is no parent revision or the slot is not defined, this returns null.
501 *
502 * @param string $role slot role name
503 * @return Content|null
504 */
505 private function getParentContent( $role ) {
506 $parent = $this->grabParentRevision();
507
508 if ( $parent && $parent->hasSlot( $role ) ) {
509 return $parent->getContent( $role, RevisionRecord::RAW );
510 }
511
512 return null;
513 }
514
515 /**
516 * @param string $role slot role name
517 * @return ContentHandler
518 */
519 private function getContentHandler( $role ) {
520 // TODO: inject something like a ContentHandlerRegistry
521 if ( $this->slotsUpdate->isModifiedSlot( $role ) ) {
522 $slot = $this->slotsUpdate->getModifiedSlot( $role );
523 } else {
524 $parent = $this->grabParentRevision();
525
526 if ( $parent ) {
527 $slot = $parent->getSlot( $role, RevisionRecord::RAW );
528 } else {
529 throw new RevisionAccessException( 'No such slot: ' . $role );
530 }
531 }
532
533 return ContentHandler::getForModelID( $slot->getModel() );
534 }
535
536 /**
537 * @param int $flags Bit mask: a bit mask of EDIT_XXX flags.
538 *
539 * @return CommentStoreComment
540 */
541 private function makeAutoSummary( $flags ) {
542 if ( !$this->useAutomaticEditSummaries || ( $flags & EDIT_AUTOSUMMARY ) === 0 ) {
543 return CommentStoreComment::newUnsavedComment( '' );
544 }
545
546 // NOTE: this generates an auto-summary for SOME RANDOM changed slot!
547 // TODO: combine auto-summaries for multiple slots!
548 // XXX: this logic should not be in the storage layer!
549 $roles = $this->slotsUpdate->getModifiedRoles();
550 $role = reset( $roles );
551
552 if ( $role === false ) {
553 return CommentStoreComment::newUnsavedComment( '' );
554 }
555
556 $handler = $this->getContentHandler( $role );
557 $content = $this->slotsUpdate->getModifiedSlot( $role )->getContent();
558 $old_content = $this->getParentContent( $role );
559 $summary = $handler->getAutosummary( $old_content, $content, $flags );
560
561 return CommentStoreComment::newUnsavedComment( $summary );
562 }
563
564 /**
565 * Change an existing article or create a new article. Updates RC and all necessary caches,
566 * optionally via the deferred update array. This does not check user permissions.
567 *
568 * It is guaranteed that saveRevision() will fail if the current revision of the page
569 * changes after grabParentRevision() was called and before saveRevision() can insert
570 * a new revision, as per the CAS mechanism described above.
571 *
572 * The caller is however responsible for calling hasEditConflict() to detect a
573 * user-level edit conflict, and to adjust the content of the new revision accordingly,
574 * e.g. by using a 3-way-merge.
575 *
576 * MCR migration note: this replaces WikiPage::doEditContent. Callers that change to using
577 * saveRevision() now need to check the "minoredit" themselves before using EDIT_MINOR.
578 *
579 * @param CommentStoreComment $summary Edit summary
580 * @param int $flags Bitfield:
581 * EDIT_NEW
582 * Create a new page, or fail with "edit-already-exists" if the page exists.
583 * EDIT_UPDATE
584 * Create a new revision, or fail with "edit-gone-missing" if the page does not exist.
585 * EDIT_MINOR
586 * Mark this revision as minor
587 * EDIT_SUPPRESS_RC
588 * Do not log the change in recentchanges
589 * EDIT_FORCE_BOT
590 * Mark the revision as automated ("bot edit")
591 * EDIT_AUTOSUMMARY
592 * Fill in blank summaries with generated text where possible
593 * EDIT_INTERNAL
594 * Signal that the page retrieve/save cycle happened entirely in this request.
595 *
596 * If neither EDIT_NEW nor EDIT_UPDATE is specified, the expected state is detected
597 * automatically via grabParentRevision(). In this case, the "edit-already-exists" or
598 * "edit-gone-missing" errors may still be triggered due to race conditions, if the page
599 * was unexpectedly created or deleted while revision creation is in progress. This can be
600 * viewed as part of the CAS mechanism described above.
601 *
602 * @return RevisionRecord|null The new revision, or null if no new revision was created due
603 * to a failure or a null-edit. Use isUnchanged(), wasSuccessful() and getStatus()
604 * to determine the outcome of the revision creation.
605 *
606 * @throws MWException
607 * @throws RuntimeException
608 */
609 public function saveRevision( CommentStoreComment $summary, $flags = 0 ) {
610 // Defend against mistakes caused by differences with the
611 // signature of WikiPage::doEditContent.
612 Assert::parameterType( 'integer', $flags, '$flags' );
613 Assert::parameterType( 'CommentStoreComment', $summary, '$summary' );
614
615 if ( $this->wasCommitted() ) {
616 throw new RuntimeException( 'saveRevision() has already been called on this PageUpdater!' );
617 }
618
619 // Low-level sanity check
620 if ( $this->getLinkTarget()->getText() === '' ) {
621 throw new RuntimeException( 'Something is trying to edit an article with an empty title' );
622 }
623
624 // TODO: MCR: check the role and the content's model against the list of supported
625 // and required roles, see T194046.
626
627 // Make sure the given content type is allowed for this page
628 // TODO: decide: Extend check to other slots? Consider the role in check? [PageType]
629 $mainContentHandler = $this->getContentHandler( 'main' );
630 if ( !$mainContentHandler->canBeUsedOn( $this->getTitle() ) ) {
631 $this->status = Status::newFatal( 'content-not-allowed-here',
632 ContentHandler::getLocalizedName( $mainContentHandler->getModelID() ),
633 $this->getTitle()->getPrefixedText()
634 );
635 return null;
636 }
637
638 // Load the data from the master database if needed. Needed to check flags.
639 // NOTE: This grabs the parent revision as the CAS token, if grabParentRevision
640 // wasn't called yet. If the page is modified by another process before we are done with
641 // it, this method must fail (with status 'edit-conflict')!
642 // NOTE: The parent revision may be different from $this->baseRevisionId.
643 $this->grabParentRevision();
644 $flags = $this->checkFlags( $flags );
645
646 // Avoid statsd noise and wasted cycles check the edit stash (T136678)
647 if ( ( $flags & EDIT_INTERNAL ) || ( $flags & EDIT_FORCE_BOT ) ) {
648 $useStashed = false;
649 } else {
650 $useStashed = $this->ajaxEditStash;
651 }
652
653 // TODO: use this only for the legacy hook, and only if something uses the legacy hook
654 $wikiPage = $this->getWikiPage();
655
656 $user = $this->user;
657
658 // Prepare the update. This performs PST and generates the canonical ParserOutput.
659 $this->derivedDataUpdater->prepareContent(
660 $this->user,
661 $this->slotsUpdate,
662 $useStashed
663 );
664
665 // TODO: don't force initialization here!
666 // This is a hack to work around the fact that late initialization of the ParserOutput
667 // causes ApiFlowEditHeaderTest::testCache to fail. Whether that failure indicates an
668 // actual problem, or is just an issue with the test setup, remains to be determined
669 // [dk, 2018-03].
670 // Anomie said in 2018-03:
671 /*
672 I suspect that what's breaking is this:
673
674 The old version of WikiPage::doEditContent() called prepareContentForEdit() which
675 generated the ParserOutput right then, so when doEditUpdates() gets called from the
676 DeferredUpdate scheduled by WikiPage::doCreate() there's no need to parse. I note
677 there's a comment there that says "Get the pre-save transform content and final
678 parser output".
679 The new version of WikiPage::doEditContent() makes a PageUpdater and calls its
680 saveRevision(), which calls DerivedPageDataUpdater::prepareContent() and
681 PageUpdater::doCreate() without ever having to actually generate a ParserOutput.
682 Thus, when DerivedPageDataUpdater::doUpdates() is called from the DeferredUpdate
683 scheduled by PageUpdater::doCreate(), it does find that it needs to parse at that point.
684
685 And the order of operations in that Flow test is presumably:
686
687 - Create a page with a call to WikiPage::doEditContent(), in a way that somehow avoids
688 processing the DeferredUpdate.
689 - Set up the "no set!" mock cache in Flow\Tests\Api\ApiTestCase::expectCacheInvalidate()
690 - Then, during the course of doing that test, a $db->commit() results in the
691 DeferredUpdates being run.
692 */
693 $this->derivedDataUpdater->getCanonicalParserOutput();
694
695 $mainContent = $this->derivedDataUpdater->getSlots()->getContent( 'main' );
696
697 // Trigger pre-save hook (using provided edit summary)
698 $hookStatus = Status::newGood( [] );
699 // TODO: replace legacy hook!
700 // TODO: avoid pass-by-reference, see T193950
701 $hook_args = [ &$wikiPage, &$user, &$mainContent, &$summary,
702 $flags & EDIT_MINOR, null, null, &$flags, &$hookStatus ];
703 // Check if the hook rejected the attempted save
704 if ( !Hooks::run( 'PageContentSave', $hook_args ) ) {
705 if ( $hookStatus->isOK() ) {
706 // Hook returned false but didn't call fatal(); use generic message
707 $hookStatus->fatal( 'edit-hook-aborted' );
708 }
709
710 $this->status = $hookStatus;
711 return null;
712 }
713
714 // Provide autosummaries if one is not provided and autosummaries are enabled
715 // XXX: $summary == null seems logical, but the empty string may actually come from the user
716 // XXX: Move this logic out of the storage layer! It does not belong here! Use a callback?
717 if ( $summary->text === '' && $summary->data === null ) {
718 $summary = $this->makeAutoSummary( $flags );
719 }
720
721 // Actually create the revision and create/update the page.
722 // Do NOT yet set $this->status!
723 if ( $flags & EDIT_UPDATE ) {
724 $status = $this->doModify( $summary, $this->user, $flags );
725 } else {
726 $status = $this->doCreate( $summary, $this->user, $flags );
727 }
728
729 // Promote user to any groups they meet the criteria for
730 DeferredUpdates::addCallableUpdate( function () use ( $user ) {
731 $user->addAutopromoteOnceGroups( 'onEdit' );
732 $user->addAutopromoteOnceGroups( 'onView' ); // b/c
733 } );
734
735 // NOTE: set $this->status only after all hooks have been called,
736 // so wasCommitted doesn't return true wehn called indirectly from a hook handler!
737 $this->status = $status;
738
739 // TODO: replace bad status with Exceptions!
740 return ( $this->status && $this->status->isOK() )
741 ? $this->status->value['revision-record']
742 : null;
743 }
744
745 /**
746 * Whether saveRevision() has been called on this instance
747 *
748 * @return bool
749 */
750 public function wasCommitted() {
751 return $this->status !== null;
752 }
753
754 /**
755 * The Status object indicating whether saveRevision() was successful, or null if
756 * saveRevision() was not yet called on this instance.
757 *
758 * @note This is here for compatibility with WikiPage::doEditContent. It may be deprecated
759 * soon.
760 *
761 * Possible status errors:
762 * edit-hook-aborted: The ArticleSave hook aborted the update but didn't
763 * set the fatal flag of $status.
764 * edit-gone-missing: In update mode, but the article didn't exist.
765 * edit-conflict: In update mode, the article changed unexpectedly.
766 * edit-no-change: Warning that the text was the same as before.
767 * edit-already-exists: In creation mode, but the article already exists.
768 *
769 * Extensions may define additional errors.
770 *
771 * $return->value will contain an associative array with members as follows:
772 * new: Boolean indicating if the function attempted to create a new article.
773 * revision: The revision object for the inserted revision, or null.
774 *
775 * @return null|Status
776 */
777 public function getStatus() {
778 return $this->status;
779 }
780
781 /**
782 * Whether saveRevision() completed successfully
783 *
784 * @return bool
785 */
786 public function wasSuccessful() {
787 return $this->status && $this->status->isOK();
788 }
789
790 /**
791 * Whether saveRevision() was called and created a new page.
792 *
793 * @return bool
794 */
795 public function isNew() {
796 return $this->status && $this->status->isOK() && $this->status->value['new'];
797 }
798
799 /**
800 * Whether saveRevision() did not create a revision because the content didn't change
801 * (null-edit). Whether the content changed or not is determined by
802 * DerivedPageDataUpdater::isChange().
803 *
804 * @return bool
805 */
806 public function isUnchanged() {
807 return $this->status
808 && $this->status->isOK()
809 && $this->status->value['revision-record'] === null;
810 }
811
812 /**
813 * The new revision created by saveRevision(), or null if saveRevision() has not yet been
814 * called, failed, or did not create a new revision because the content did not change.
815 *
816 * @return RevisionRecord|null
817 */
818 public function getNewRevision() {
819 return ( $this->status && $this->status->isOK() )
820 ? $this->status->value['revision-record']
821 : null;
822 }
823
824 /**
825 * Constructs a MutableRevisionRecord based on the Content prepared by the
826 * DerivedPageDataUpdater. This takes care of inheriting slots, updating slots
827 * with PST applied, and removing discontinued slots.
828 *
829 * This calls Content::prepareSave() to verify that the slot content can be saved.
830 * The $status parameter is updated with any errors or warnings found by Content::prepareSave().
831 *
832 * @param CommentStoreComment $comment
833 * @param User $user
834 * @param string $timestamp
835 * @param int $flags
836 * @param Status $status
837 *
838 * @return MutableRevisionRecord
839 */
840 private function makeNewRevision(
841 CommentStoreComment $comment,
842 User $user,
843 $timestamp,
844 $flags,
845 Status $status
846 ) {
847 $wikiPage = $this->getWikiPage();
848 $title = $this->getTitle();
849 $parent = $this->grabParentRevision();
850
851 $rev = new MutableRevisionRecord( $title, $this->getWikiId() );
852 $rev->setPageId( $title->getArticleID() );
853
854 if ( $parent ) {
855 $oldid = $parent->getId();
856 $rev->setParentId( $oldid );
857 } else {
858 $oldid = 0;
859 }
860
861 $rev->setComment( $comment );
862 $rev->setUser( $user );
863 $rev->setTimestamp( $timestamp );
864 $rev->setMinorEdit( ( $flags & EDIT_MINOR ) > 0 );
865
866 foreach ( $this->derivedDataUpdater->getSlots()->getSlots() as $slot ) {
867 $content = $slot->getContent();
868
869 // XXX: We may push this up to the "edit controller" level, see T192777.
870 // TODO: change the signature of PrepareSave to not take a WikiPage!
871 $prepStatus = $content->prepareSave( $wikiPage, $flags, $oldid, $user );
872
873 if ( $prepStatus->isOK() ) {
874 $rev->setSlot( $slot );
875 }
876
877 // TODO: MCR: record which problem arose in which slot.
878 $status->merge( $prepStatus );
879 }
880
881 return $rev;
882 }
883
884 /**
885 * @param CommentStoreComment $summary The edit summary
886 * @param User $user The revision's author
887 * @param int $flags EXIT_XXX constants
888 *
889 * @throws MWException
890 * @return Status
891 */
892 private function doModify( CommentStoreComment $summary, User $user, $flags ) {
893 $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
894
895 // Update article, but only if changed.
896 $status = Status::newGood( [ 'new' => false, 'revision' => null, 'revision-record' => null ] );
897
898 // Convenience variables
899 $now = $this->getTimestampNow();
900
901 $oldRev = $this->grabParentRevision();
902 $oldid = $oldRev ? $oldRev->getId() : 0;
903
904 if ( !$oldRev ) {
905 // Article gone missing
906 $status->fatal( 'edit-gone-missing' );
907
908 return $status;
909 }
910
911 $newRevisionRecord = $this->makeNewRevision(
912 $summary,
913 $user,
914 $now,
915 $flags,
916 $status
917 );
918
919 if ( !$status->isOK() ) {
920 return $status;
921 }
922
923 // XXX: we may want a flag that allows a null revision to be forced!
924 $changed = $this->derivedDataUpdater->isChange();
925 $mainContent = $newRevisionRecord->getContent( 'main' );
926
927 $dbw = $this->getDBConnectionRef( DB_MASTER );
928
929 if ( $changed ) {
930 $dbw->startAtomic( __METHOD__ );
931
932 // Get the latest page_latest value while locking it.
933 // Do a CAS style check to see if it's the same as when this method
934 // started. If it changed then bail out before touching the DB.
935 $latestNow = $wikiPage->lockAndGetLatest(); // TODO: move to storage service, pass DB
936 if ( $latestNow != $oldid ) {
937 // We don't need to roll back, since we did not modify the database yet.
938 // XXX: Or do we want to rollback, any transaction started by calling
939 // code will fail? If we want that, we should probably throw an exception.
940 $dbw->endAtomic( __METHOD__ );
941 // Page updated or deleted in the mean time
942 $status->fatal( 'edit-conflict' );
943
944 return $status;
945 }
946
947 // At this point we are now comitted to returning an OK
948 // status unless some DB query error or other exception comes up.
949 // This way callers don't have to call rollback() if $status is bad
950 // unless they actually try to catch exceptions (which is rare).
951
952 // Save revision content and meta-data
953 $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw );
954 $newLegacyRevision = new Revision( $newRevisionRecord );
955
956 // Update page_latest and friends to reflect the new revision
957 // TODO: move to storage service
958 $wasRedirect = $this->derivedDataUpdater->wasRedirect();
959 if ( !$wikiPage->updateRevisionOn( $dbw, $newLegacyRevision, null, $wasRedirect ) ) {
960 throw new PageUpdateException( "Failed to update page row to use new revision." );
961 }
962
963 // TODO: replace legacy hook!
964 $tags = $this->computeEffectiveTags( $flags );
965 Hooks::run(
966 'NewRevisionFromEditComplete',
967 [ $wikiPage, $newLegacyRevision, $this->getOriginalRevisionId(), $user, &$tags ]
968 );
969
970 // Update recentchanges
971 if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
972 // Add RC row to the DB
973 RecentChange::notifyEdit(
974 $now,
975 $this->getTitle(),
976 $newRevisionRecord->isMinor(),
977 $user,
978 $summary->text, // TODO: pass object when that becomes possible
979 $oldid,
980 $newRevisionRecord->getTimestamp(),
981 ( $flags & EDIT_FORCE_BOT ) > 0,
982 '',
983 $oldRev->getSize(),
984 $newRevisionRecord->getSize(),
985 $newRevisionRecord->getId(),
986 $this->rcPatrolStatus,
987 $tags
988 );
989 }
990
991 $user->incEditCount();
992
993 $dbw->endAtomic( __METHOD__ );
994 } else {
995 // T34948: revision ID must be set to page {{REVISIONID}} and
996 // related variables correctly. Likewise for {{REVISIONUSER}} (T135261).
997 // Since we don't insert a new revision into the database, the least
998 // error-prone way is to reuse given old revision.
999 $newRevisionRecord = $oldRev;
1000 $newLegacyRevision = new Revision( $newRevisionRecord );
1001 }
1002
1003 if ( $changed ) {
1004 // Return the new revision to the caller
1005 $status->value['revision-record'] = $newRevisionRecord;
1006
1007 // TODO: globally replace usages of 'revision' with getNewRevision()
1008 $status->value['revision'] = $newLegacyRevision;
1009 } else {
1010 $status->warning( 'edit-no-change' );
1011 // Update page_touched as updateRevisionOn() was not called.
1012 // Other cache updates are managed in WikiPage::onArticleEdit()
1013 // via WikiPage::doEditUpdates().
1014 $this->getTitle()->invalidateCache( $now );
1015 }
1016
1017 // Do secondary updates once the main changes have been committed...
1018 // NOTE: the updates have to be processed before sending the response to the client
1019 // (DeferredUpdates::PRESEND), otherwise the client may already be following the
1020 // HTTP redirect to the standard view before dervide data has been created - most
1021 // importantly, before the parser cache has been updated. This would cause the
1022 // content to be parsed a second time, or may cause stale content to be shown.
1023 DeferredUpdates::addUpdate(
1024 new AtomicSectionUpdate(
1025 $dbw,
1026 __METHOD__,
1027 function () use (
1028 $wikiPage, $newRevisionRecord, $newLegacyRevision, $user, $mainContent,
1029 $summary, $flags, $changed, $status
1030 ) {
1031 // Update links tables, site stats, etc.
1032 $this->derivedDataUpdater->prepareUpdate(
1033 $newRevisionRecord,
1034 [
1035 'changed' => $changed,
1036 ]
1037 );
1038 $this->derivedDataUpdater->doUpdates();
1039
1040 // Trigger post-save hook
1041 // TODO: replace legacy hook!
1042 // TODO: avoid pass-by-reference, see T193950
1043 $params = [ &$wikiPage, &$user, $mainContent, $summary->text, $flags & EDIT_MINOR,
1044 null, null, &$flags, $newLegacyRevision, &$status, $this->getOriginalRevisionId(),
1045 $this->undidRevId ];
1046 Hooks::run( 'PageContentSaveComplete', $params );
1047 }
1048 ),
1049 DeferredUpdates::PRESEND
1050 );
1051
1052 return $status;
1053 }
1054
1055 /**
1056 * @param CommentStoreComment $summary The edit summary
1057 * @param User $user The revision's author
1058 * @param int $flags EXIT_XXX constants
1059 *
1060 * @throws DBUnexpectedError
1061 * @throws MWException
1062 * @return Status
1063 */
1064 private function doCreate( CommentStoreComment $summary, User $user, $flags ) {
1065 $wikiPage = $this->getWikiPage(); // TODO: use for legacy hooks only!
1066
1067 if ( !$this->derivedDataUpdater->getSlots()->hasSlot( 'main' ) ) {
1068 throw new PageUpdateException( 'Must provide a main slot when creating a page!' );
1069 }
1070
1071 $status = Status::newGood( [ 'new' => true, 'revision' => null, 'revision-record' => null ] );
1072
1073 $now = $this->getTimestampNow();
1074
1075 $newRevisionRecord = $this->makeNewRevision(
1076 $summary,
1077 $user,
1078 $now,
1079 $flags,
1080 $status
1081 );
1082
1083 if ( !$status->isOK() ) {
1084 return $status;
1085 }
1086
1087 $dbw = $this->getDBConnectionRef( DB_MASTER );
1088 $dbw->startAtomic( __METHOD__ );
1089
1090 // Add the page record unless one already exists for the title
1091 // TODO: move to storage service
1092 $newid = $wikiPage->insertOn( $dbw );
1093 if ( $newid === false ) {
1094 $dbw->endAtomic( __METHOD__ ); // nothing inserted
1095 $status->fatal( 'edit-already-exists' );
1096
1097 return $status; // nothing done
1098 }
1099
1100 // At this point we are now comitted to returning an OK
1101 // status unless some DB query error or other exception comes up.
1102 // This way callers don't have to call rollback() if $status is bad
1103 // unless they actually try to catch exceptions (which is rare).
1104 $newRevisionRecord->setPageId( $newid );
1105
1106 // Save the revision text...
1107 $newRevisionRecord = $this->revisionStore->insertRevisionOn( $newRevisionRecord, $dbw );
1108 $newLegacyRevision = new Revision( $newRevisionRecord );
1109
1110 // Update the page record with revision data
1111 // TODO: move to storage service
1112 if ( !$wikiPage->updateRevisionOn( $dbw, $newLegacyRevision, 0 ) ) {
1113 throw new PageUpdateException( "Failed to update page row to use new revision." );
1114 }
1115
1116 // TODO: replace legacy hook!
1117 $tags = $this->computeEffectiveTags( $flags );
1118 Hooks::run(
1119 'NewRevisionFromEditComplete',
1120 [ $wikiPage, $newLegacyRevision, false, $user, &$tags ]
1121 );
1122
1123 // Update recentchanges
1124 if ( !( $flags & EDIT_SUPPRESS_RC ) ) {
1125 // Add RC row to the DB
1126 RecentChange::notifyNew(
1127 $now,
1128 $this->getTitle(),
1129 $newRevisionRecord->isMinor(),
1130 $user,
1131 $summary->text, // TODO: pass object when that becomes possible
1132 ( $flags & EDIT_FORCE_BOT ) > 0,
1133 '',
1134 $newRevisionRecord->getSize(),
1135 $newRevisionRecord->getId(),
1136 $this->rcPatrolStatus,
1137 $tags
1138 );
1139 }
1140
1141 $user->incEditCount();
1142
1143 if ( $this->usePageCreationLog ) {
1144 // Log the page creation
1145 // @TODO: Do we want a 'recreate' action?
1146 $logEntry = new ManualLogEntry( 'create', 'create' );
1147 $logEntry->setPerformer( $user );
1148 $logEntry->setTarget( $this->getTitle() );
1149 $logEntry->setComment( $summary->text );
1150 $logEntry->setTimestamp( $now );
1151 $logEntry->setAssociatedRevId( $newRevisionRecord->getId() );
1152 $logEntry->insert();
1153 // Note that we don't publish page creation events to recentchanges
1154 // (i.e. $logEntry->publish()) since this would create duplicate entries,
1155 // one for the edit and one for the page creation.
1156 }
1157
1158 $dbw->endAtomic( __METHOD__ );
1159
1160 // Return the new revision to the caller
1161 // TODO: globally replace usages of 'revision' with getNewRevision()
1162 $status->value['revision'] = $newLegacyRevision;
1163 $status->value['revision-record'] = $newRevisionRecord;
1164
1165 // XXX: make sure we are not loading the Content from the DB
1166 $mainContent = $newRevisionRecord->getContent( 'main' );
1167
1168 // Do secondary updates once the main changes have been committed...
1169 DeferredUpdates::addUpdate(
1170 new AtomicSectionUpdate(
1171 $dbw,
1172 __METHOD__,
1173 function () use (
1174 $wikiPage,
1175 $newRevisionRecord,
1176 $newLegacyRevision,
1177 $user,
1178 $mainContent,
1179 $summary,
1180 $flags,
1181 $status
1182 ) {
1183 // Update links, etc.
1184 $this->derivedDataUpdater->prepareUpdate(
1185 $newRevisionRecord,
1186 [ 'created' => true ]
1187 );
1188 $this->derivedDataUpdater->doUpdates();
1189
1190 // Trigger post-create hook
1191 // TODO: replace legacy hook!
1192 // TODO: avoid pass-by-reference, see T193950
1193 $params = [ &$wikiPage, &$user, $mainContent, $summary->text,
1194 $flags & EDIT_MINOR, null, null, &$flags, $newLegacyRevision ];
1195 Hooks::run( 'PageContentInsertComplete', $params );
1196 // Trigger post-save hook
1197 // TODO: replace legacy hook!
1198 $params = array_merge( $params, [ &$status, $this->getOriginalRevisionId(), 0 ] );
1199 Hooks::run( 'PageContentSaveComplete', $params );
1200 }
1201 ),
1202 DeferredUpdates::PRESEND
1203 );
1204
1205 return $status;
1206 }
1207
1208 }