PageUpdater.php 42 KB

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