Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
42.22% |
57 / 135 |
|
22.22% |
2 / 9 |
CRAP | |
0.00% |
0 / 1 |
ContentModelChange | |
42.22% |
57 / 135 |
|
22.22% |
2 / 9 |
145.55 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
setMessagePrefix | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
authorizeInternal | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
probablyCanChange | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
authorizeChange | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
checkPermissions | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
setTags | |
60.00% |
3 / 5 |
|
0.00% |
0 / 1 |
2.26 | |||
createNewContent | |
0.00% |
0 / 40 |
|
0.00% |
0 / 1 |
42 | |||
doContentModelChange | |
81.48% |
44 / 54 |
|
0.00% |
0 / 1 |
11.77 |
1 | <?php |
2 | |
3 | use MediaWiki\Content\IContentHandlerFactory; |
4 | use MediaWiki\Context\DerivativeContext; |
5 | use MediaWiki\Context\IContextSource; |
6 | use MediaWiki\Context\RequestContext; |
7 | use MediaWiki\HookContainer\HookContainer; |
8 | use MediaWiki\HookContainer\HookRunner; |
9 | use MediaWiki\Page\PageIdentity; |
10 | use MediaWiki\Permissions\Authority; |
11 | use MediaWiki\Permissions\PermissionStatus; |
12 | use MediaWiki\Revision\RevisionLookup; |
13 | use MediaWiki\Revision\SlotRecord; |
14 | use MediaWiki\Status\Status; |
15 | use 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 | */ |
26 | class 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 | } |