Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
41.91% covered (danger)
41.91%
57 / 136
22.22% covered (danger)
22.22%
2 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContentModelChange
41.91% covered (danger)
41.91%
57 / 136
22.22% covered (danger)
22.22%
2 / 9
147.50
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 setMessagePrefix
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 authorizeInternal
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 probablyCanChange
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 authorizeChange
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 checkPermissions
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 setTags
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 createNewContent
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
42
 doContentModelChange
81.48% covered (warning)
81.48%
44 / 54
0.00% covered (danger)
0.00%
0 / 1
11.77
1<?php
2
3use MediaWiki\Content\IContentHandlerFactory;
4use MediaWiki\Context\DerivativeContext;
5use MediaWiki\Context\IContextSource;
6use MediaWiki\Context\RequestContext;
7use MediaWiki\HookContainer\HookContainer;
8use MediaWiki\HookContainer\HookRunner;
9use MediaWiki\Message\Message;
10use MediaWiki\Page\PageIdentity;
11use MediaWiki\Page\WikiPageFactory;
12use MediaWiki\Permissions\Authority;
13use MediaWiki\Permissions\PermissionStatus;
14use MediaWiki\Revision\RevisionLookup;
15use MediaWiki\Revision\SlotRecord;
16use MediaWiki\Status\Status;
17use MediaWiki\User\UserFactory;
18
19/**
20 * Backend logic for changing the content model of a page.
21 *
22 * Note that you can create a new page directly with a desired content
23 * model and format, e.g. via EditPage or externally from ApiEditPage.
24 *
25 * @since 1.35
26 * @author DannyS712
27 */
28class ContentModelChange {
29    /** @var IContentHandlerFactory */
30    private $contentHandlerFactory;
31    /** @var HookRunner */
32    private $hookRunner;
33    /** @var RevisionLookup */
34    private $revLookup;
35    /** @var UserFactory */
36    private $userFactory;
37    /** @var Authority making the change */
38    private $performer;
39    /** @var WikiPage */
40    private $page;
41    /** @var PageIdentity */
42    private $pageIdentity;
43    /** @var string */
44    private $newModel;
45    /** @var string[] tags to add */
46    private $tags;
47    /** @var Content */
48    private $newContent;
49    /** @var int|false latest revision id, or false if creating */
50    private $latestRevId;
51    /** @var string 'new' or 'change' */
52    private $logAction;
53    /** @var string 'apierror-' or empty string, for status messages */
54    private $msgPrefix;
55
56    /**
57     * @internal Create via the ContentModelChangeFactory service.
58     * @param IContentHandlerFactory $contentHandlerFactory
59     * @param HookContainer $hookContainer
60     * @param RevisionLookup $revLookup
61     * @param UserFactory $userFactory
62     * @param WikiPageFactory $wikiPageFactory
63     * @param Authority $performer
64     * @param PageIdentity $page
65     * @param string $newModel
66     */
67    public function __construct(
68        IContentHandlerFactory $contentHandlerFactory,
69        HookContainer $hookContainer,
70        RevisionLookup $revLookup,
71        UserFactory $userFactory,
72        WikiPageFactory $wikiPageFactory,
73        Authority $performer,
74        PageIdentity $page,
75        string $newModel
76    ) {
77        $this->contentHandlerFactory = $contentHandlerFactory;
78        $this->hookRunner = new HookRunner( $hookContainer );
79        $this->revLookup = $revLookup;
80        $this->userFactory = $userFactory;
81
82        $this->performer = $performer;
83        $this->page = $wikiPageFactory->newFromTitle( $page );
84        $this->pageIdentity = $page;
85        $this->newModel = $newModel;
86
87        // SpecialChangeContentModel doesn't support tags
88        // api can specify tags via ::setTags, which also checks if user can add
89        // the tags specified
90        $this->tags = [];
91
92        // Requires createNewContent to be called first
93        $this->logAction = '';
94
95        // Defaults to nothing, for special page
96        $this->msgPrefix = '';
97    }
98
99    /**
100     * @param string $msgPrefix
101     */
102    public function setMessagePrefix( $msgPrefix ) {
103        $this->msgPrefix = $msgPrefix;
104    }
105
106    /**
107     * @param callable $authorizer ( string $action, PageIdentity $target, PermissionStatus $status )
108     * @return PermissionStatus
109     */
110    private function authorizeInternal( callable $authorizer ): PermissionStatus {
111        $current = $this->page->getTitle();
112        $titleWithNewContentModel = clone $current;
113        $titleWithNewContentModel->setContentModel( $this->newModel );
114
115        $status = PermissionStatus::newEmpty();
116        $authorizer( 'editcontentmodel', $this->pageIdentity, $status );
117        $authorizer( 'edit', $this->pageIdentity, $status );
118        $authorizer( 'editcontentmodel', $titleWithNewContentModel, $status );
119        $authorizer( 'edit', $titleWithNewContentModel, $status );
120        return $status;
121    }
122
123    /**
124     * Check whether $performer can execute the content model change.
125     *
126     * @note this method does not guarantee full permissions check, so it should
127     * only be used to to decide whether to show a content model change form.
128     * To authorize the content model change action use {@link self::authorizeChange} instead.
129     *
130     * @return PermissionStatus
131     */
132    public function probablyCanChange(): PermissionStatus {
133        return $this->authorizeInternal(
134            function ( string $action, PageIdentity $target, PermissionStatus $status ) {
135                return $this->performer->probablyCan( $action, $target, $status );
136            }
137        );
138    }
139
140    /**
141     * Authorize the content model change by $performer.
142     *
143     * @note this method should be used right before the actual content model change is performed.
144     * To check whether a current performer has the potential to change the content model of the page,
145     * use {@link self::probablyCanChange} instead.
146     *
147     * @return PermissionStatus
148     */
149    public function authorizeChange(): PermissionStatus {
150        return $this->authorizeInternal(
151            function ( string $action, PageIdentity $target, PermissionStatus $status ) {
152                return $this->performer->authorizeWrite( $action, $target, $status );
153            }
154        );
155    }
156
157    /**
158     * Check user can edit and editcontentmodel before and after
159     *
160     * @deprecated since 1.36. Use ::probablyCanChange or ::authorizeChange instead.
161     * @return array from wfMergeErrorArrays
162     */
163    public function checkPermissions() {
164        wfDeprecated( __METHOD__, '1.36' );
165        $status = $this->authorizeInternal(
166            function ( string $action, PageIdentity $target, PermissionStatus $status ) {
167                return $this->performer->definitelyCan( $action, $target, $status );
168            } );
169        return $status->toLegacyErrorArray();
170    }
171
172    /**
173     * Specify the tags the user wants to add, and check permissions
174     *
175     * @param string[] $tags
176     * @return Status
177     */
178    public function setTags( $tags ) {
179        $tagStatus = ChangeTags::canAddTagsAccompanyingChange( $tags, $this->performer );
180        if ( $tagStatus->isOK() ) {
181            $this->tags = $tags;
182            return Status::newGood();
183        } else {
184            return $tagStatus;
185        }
186    }
187
188    /**
189     * @return Status
190     */
191    private function createNewContent() {
192        $contentHandlerFactory = $this->contentHandlerFactory;
193
194        $title = $this->page->getTitle();
195        $latestRevRecord = $this->revLookup->getRevisionByTitle( $this->pageIdentity );
196
197        if ( $latestRevRecord ) {
198            $latestContent = $latestRevRecord->getContent( SlotRecord::MAIN );
199            $latestHandler = $latestContent->getContentHandler();
200            $latestModel = $latestContent->getModel();
201            if ( !$latestHandler->supportsDirectEditing() ) {
202                // Only reachable via api
203                return Status::newFatal(
204                    'apierror-changecontentmodel-nodirectediting',
205                    ContentHandler::getLocalizedName( $latestModel )
206                );
207            }
208
209            $newModel = $this->newModel;
210            if ( $newModel === $latestModel ) {
211                // Only reachable via api
212                return Status::newFatal( 'apierror-nochanges' );
213            }
214            $newHandler = $contentHandlerFactory->getContentHandler( $newModel );
215            if ( !$newHandler->canBeUsedOn( $title ) ) {
216                // Only reachable via api
217                return Status::newFatal(
218                    'apierror-changecontentmodel-cannotbeused',
219                    ContentHandler::getLocalizedName( $newModel ),
220                    Message::plaintextParam( $title->getPrefixedText() )
221                );
222            }
223
224            try {
225                $newContent = $newHandler->unserializeContent(
226                    $latestContent->serialize()
227                );
228            } catch ( MWException $e ) {
229                // Messages: changecontentmodel-cannot-convert,
230                // apierror-changecontentmodel-cannot-convert
231                return Status::newFatal(
232                    $this->msgPrefix . 'changecontentmodel-cannot-convert',
233                    Message::plaintextParam( $title->getPrefixedText() ),
234                    ContentHandler::getLocalizedName( $newModel )
235                );
236            }
237            $this->latestRevId = $latestRevRecord->getId();
238            $this->logAction = 'change';
239        } else {
240            // Page doesn't exist, create an empty content object
241            $newContent = $contentHandlerFactory
242                ->getContentHandler( $this->newModel )
243                ->makeEmptyContent();
244            $this->latestRevId = false;
245            $this->logAction = 'new';
246        }
247        $this->newContent = $newContent;
248        return Status::newGood();
249    }
250
251    /**
252     * Handle change and logging after validation
253     *
254     * Can still be intercepted by hooks
255     *
256     * @param IContextSource $context
257     * @param string $comment
258     * @param bool $bot Mark as a bot edit if the user can
259     * @return Status
260     */
261    public function doContentModelChange(
262        IContextSource $context,
263        string $comment,
264        $bot
265    ) {
266        $status = $this->createNewContent();
267        if ( !$status->isGood() ) {
268            return $status;
269        }
270
271        $page = $this->page;
272        $title = $page->getTitle();
273        $user = $this->userFactory->newFromAuthority( $this->performer );
274
275        // Create log entry
276        $log = new ManualLogEntry( 'contentmodel', $this->logAction );
277        $log->setPerformer( $this->performer->getUser() );
278        $log->setTarget( $title );
279        $log->setComment( $comment );
280        $log->setParameters( [
281            '4::oldmodel' => $title->getContentModel(),
282            '5::newmodel' => $this->newModel
283        ] );
284        $log->addTags( $this->tags );
285
286        $formatter = LogFormatter::newFromEntry( $log );
287        $formatter->setContext( RequestContext::newExtraneousContext( $title ) );
288        $reason = $formatter->getPlainActionText();
289
290        if ( $comment !== '' ) {
291            $reason .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $comment;
292        }
293
294        // Run edit filters
295        $derivativeContext = new DerivativeContext( $context );
296        $derivativeContext->setTitle( $title );
297        $derivativeContext->setWikiPage( $page );
298        $status = new Status();
299
300        $newContent = $this->newContent;
301
302        if ( !$this->hookRunner->onEditFilterMergedContent( $derivativeContext, $newContent,
303            $status, $reason, $user, false )
304        ) {
305            if ( $status->isGood() ) {
306                // TODO: extensions should really specify an error message
307                $status->fatal( 'hookaborted' );
308            }
309            return $status;
310        }
311        if ( !$status->isOK() ) {
312            if ( !$status->getMessages() ) {
313                $status->fatal( 'hookaborted' );
314            }
315            return $status;
316        }
317
318        // Make the edit
319        $flags = $this->latestRevId ? EDIT_UPDATE : EDIT_NEW;
320        $flags |= EDIT_INTERNAL;
321        if ( $bot && $this->performer->isAllowed( 'bot' ) ) {
322            $flags |= EDIT_FORCE_BOT;
323        }
324
325        $status = $page->doUserEditContent(
326            $newContent,
327            $this->performer,
328            $reason,
329            $flags,
330            $this->latestRevId,
331            $this->tags
332        );
333
334        if ( !$status->isOK() ) {
335            return $status;
336        }
337
338        $logid = $log->insert();
339        $log->publish( $logid );
340
341        $values = [
342            'logid' => $logid
343        ];
344
345        return Status::newGood( $values );
346    }
347
348}