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