Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
77.54% |
107 / 138 |
|
44.44% |
4 / 9 |
CRAP | |
0.00% |
0 / 1 |
ContentModelChange | |
78.10% |
107 / 137 |
|
44.44% |
4 / 9 |
31.56 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
setMessagePrefix | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
authorizeInternal | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
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 | |
72.50% |
29 / 40 |
|
0.00% |
0 / 1 |
6.75 | |||
doContentModelChange | |
81.48% |
44 / 54 |
|
0.00% |
0 / 1 |
11.77 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Content; |
4 | |
5 | use ChangeTags; |
6 | use LogFormatterFactory; |
7 | use ManualLogEntry; |
8 | use MediaWiki\Context\DerivativeContext; |
9 | use MediaWiki\Context\IContextSource; |
10 | use MediaWiki\Context\RequestContext; |
11 | use MediaWiki\HookContainer\HookContainer; |
12 | use MediaWiki\HookContainer\HookRunner; |
13 | use MediaWiki\Message\Message; |
14 | use MediaWiki\Page\PageIdentity; |
15 | use MediaWiki\Page\WikiPageFactory; |
16 | use MediaWiki\Permissions\Authority; |
17 | use MediaWiki\Permissions\PermissionStatus; |
18 | use MediaWiki\Revision\RevisionLookup; |
19 | use MediaWiki\Revision\SlotRecord; |
20 | use MediaWiki\Status\Status; |
21 | use MediaWiki\User\UserFactory; |
22 | use MWException; |
23 | use 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 | */ |
34 | class 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 */ |
370 | class_alias( ContentModelChange::class, 'ContentModelChange' ); |