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