Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.78% |
4 / 511 |
|
0.00% |
0 / 26 |
CRAP | |
0.00% |
0 / 1 |
TopicBlock | |
0.78% |
4 / 511 |
|
0.00% |
0 / 26 |
29066.69 | |
0.00% |
0 / 1 |
__construct | |
40.00% |
4 / 10 |
|
0.00% |
0 / 1 |
4.94 | |||
validate | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
156 | |||
validateEditTitle | |
0.00% |
0 / 45 |
|
0.00% |
0 / 1 |
132 | |||
validateReply | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
56 | |||
validateModerateTopic | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
validateModeratePost | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
doModerate | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
132 | |||
validateEditPost | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
90 | |||
commit | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
462 | |||
renderApi | |
0.00% |
0 / 52 |
|
0.00% |
0 / 1 |
702 | |||
finalizeApiOutput | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
renderDiffViewApi | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
renderSingleViewApi | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
renderTopicApi | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
56 | |||
renderPostApi | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
42 | |||
renderUndoApi | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
30 | |||
getRevisionFormatter | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
renderTopicHistoryApi | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
renderPostHistoryApi | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
processHistoryResult | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
30 | |||
loadRootPost | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
loadTopicTitle | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
30 | |||
getDisallowedErrorMessage | |
0.00% |
0 / 36 |
|
0.00% |
0 / 1 |
56 | |||
loadRequestedPost | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
110 | |||
getName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setPageTitle | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
30 |
1 | <?php |
2 | |
3 | namespace Flow\Block; |
4 | |
5 | use Flow\Container; |
6 | use Flow\Conversion\Utils; |
7 | use Flow\Data\ManagerGroup; |
8 | use Flow\Data\Pager\HistoryPager; |
9 | use Flow\Exception\DataModelException; |
10 | use Flow\Exception\FailCommitException; |
11 | use Flow\Exception\FlowException; |
12 | use Flow\Exception\InvalidActionException; |
13 | use Flow\Exception\InvalidDataException; |
14 | use Flow\Exception\InvalidInputException; |
15 | use Flow\Exception\PermissionException; |
16 | use Flow\Formatter\PostHistoryQuery; |
17 | use Flow\Formatter\RevisionFormatter; |
18 | use Flow\Formatter\RevisionViewQuery; |
19 | use Flow\Formatter\TopicHistoryQuery; |
20 | use Flow\Model\AbstractRevision; |
21 | use Flow\Model\PostRevision; |
22 | use Flow\Model\UUID; |
23 | use Flow\Model\Workflow; |
24 | use Flow\Notifications\Controller; |
25 | use Flow\Repository\RootPostLoader; |
26 | use MediaWiki\Context\RequestContext; |
27 | use MediaWiki\Language\RawMessage; |
28 | use MediaWiki\Logging\LogEventsList; |
29 | use MediaWiki\Logging\LogPage; |
30 | use MediaWiki\MediaWikiServices; |
31 | use MediaWiki\Message\Message; |
32 | use MediaWiki\Output\OutputPage; |
33 | |
34 | class TopicBlock extends AbstractBlock { |
35 | |
36 | /** |
37 | * @var PostRevision|null |
38 | */ |
39 | protected $root; |
40 | |
41 | /** |
42 | * @var PostRevision|null |
43 | */ |
44 | protected $topicTitle; |
45 | |
46 | /** |
47 | * @var RootPostLoader|null |
48 | */ |
49 | protected $rootLoader; |
50 | |
51 | /** |
52 | * @var PostRevision|null |
53 | */ |
54 | protected $newRevision; |
55 | |
56 | /** |
57 | * @var array |
58 | */ |
59 | protected $requestedPost = []; |
60 | |
61 | /** |
62 | * @var array Map of data to be passed on as |
63 | * commit metadata for event handlers |
64 | */ |
65 | protected $extraCommitMetadata = []; |
66 | |
67 | /** @inheritDoc */ |
68 | protected $supportedPostActions = [ |
69 | // Standard editing |
70 | 'edit-post', 'reply', |
71 | // Moderation |
72 | 'moderate-topic', |
73 | 'moderate-post', |
74 | // lock or unlock topic |
75 | 'lock-topic', |
76 | // Other stuff |
77 | 'edit-title', |
78 | 'undo-edit-post', |
79 | ]; |
80 | |
81 | /** @inheritDoc */ |
82 | protected $supportedGetActions = [ |
83 | 'reply', 'view', 'history', 'edit-post', 'edit-title', 'compare-post-revisions', 'single-view', |
84 | 'view-topic', 'view-topic-history', 'view-post', 'view-post-history', 'undo-edit-post', |
85 | 'moderate-topic', 'moderate-post', 'lock-topic', |
86 | ]; |
87 | |
88 | /** |
89 | * @var string[] |
90 | * @todo Fill in the template names |
91 | */ |
92 | protected $templates = [ |
93 | 'single-view' => 'single_view', |
94 | 'view' => '', |
95 | 'reply' => '', |
96 | 'history' => 'history', |
97 | 'edit-post' => '', |
98 | 'undo-edit-post' => 'undo_edit', |
99 | 'edit-title' => 'edit_title', |
100 | 'compare-post-revisions' => 'diff_view', |
101 | 'moderate-topic' => 'moderate_topic', |
102 | 'moderate-post' => 'moderate_post', |
103 | 'lock-topic' => 'lock', |
104 | ]; |
105 | |
106 | public function __construct( Workflow $workflow, ManagerGroup $storage, $root ) { |
107 | parent::__construct( $workflow, $storage ); |
108 | if ( $root instanceof PostRevision ) { |
109 | $this->root = $root; |
110 | } elseif ( $root instanceof RootPostLoader ) { |
111 | $this->rootLoader = $root; |
112 | } else { |
113 | throw new DataModelException( |
114 | 'Expected PostRevision or RootPostLoader, received: ' . |
115 | get_debug_type( $root ), |
116 | 'invalid-input' |
117 | ); |
118 | } |
119 | } |
120 | |
121 | protected function validate() { |
122 | $topicTitle = $this->loadTopicTitle(); |
123 | if ( !$topicTitle ) { |
124 | // permissions issue, self::loadTopicTitle should have added appropriate |
125 | // error messages already. |
126 | return; |
127 | } |
128 | |
129 | switch ( $this->action ) { |
130 | case 'edit-title': |
131 | $this->validateEditTitle(); |
132 | break; |
133 | |
134 | case 'reply': |
135 | $this->validateReply(); |
136 | break; |
137 | |
138 | case 'moderate-topic': |
139 | case 'lock-topic': |
140 | $this->validateModerateTopic(); |
141 | break; |
142 | |
143 | case 'moderate-post': |
144 | $this->validateModeratePost(); |
145 | break; |
146 | |
147 | case 'restore-post': |
148 | // @todo still necessary? |
149 | $this->validateModeratePost(); |
150 | break; |
151 | |
152 | case 'undo-edit-post': |
153 | case 'edit-post': |
154 | $this->validateEditPost(); |
155 | break; |
156 | |
157 | case 'edit-topic-summary': |
158 | // pseudo-action does not do anything, only includes data in api response |
159 | break; |
160 | |
161 | default: |
162 | throw new InvalidActionException( "Unexpected action: {$this->action}", 'invalid-action' ); |
163 | } |
164 | } |
165 | |
166 | protected function validateEditTitle() { |
167 | if ( $this->workflow->isNew() ) { |
168 | $this->addError( 'content', $this->context->msg( 'flow-error-no-existing-workflow' ) ); |
169 | return; |
170 | } |
171 | if ( !isset( $this->submitted['content'] ) || !is_string( $this->submitted['content'] ) ) { |
172 | $this->addError( 'content', $this->context->msg( 'flow-error-missing-title' ) ); |
173 | return; |
174 | } |
175 | $this->submitted['content'] = trim( $this->submitted['content'] ); |
176 | $len = mb_strlen( $this->submitted['content'] ); |
177 | if ( $len === 0 ) { |
178 | $this->addError( 'content', $this->context->msg( 'flow-error-missing-title' ) ); |
179 | return; |
180 | } |
181 | if ( $len > PostRevision::MAX_TOPIC_LENGTH ) { |
182 | $this->addError( 'content', $this->context->msg( |
183 | 'flow-error-title-too-long', PostRevision::MAX_TOPIC_LENGTH ) ); |
184 | return; |
185 | } |
186 | if ( empty( $this->submitted['prev_revision'] ) ) { |
187 | $this->addError( 'prev_revision', $this->context->msg( |
188 | 'flow-error-missing-prev-revision-identifier' ) ); |
189 | return; |
190 | } |
191 | $topicTitle = $this->loadTopicTitle(); |
192 | if ( !$topicTitle ) { |
193 | return; |
194 | } |
195 | if ( !$this->permissions->isAllowed( $topicTitle, 'edit-title' ) ) { |
196 | $this->addError( 'permissions', $this->getDisallowedErrorMessage( $topicTitle ) ); |
197 | return; |
198 | } |
199 | if ( $topicTitle->getRevisionId()->getAlphadecimal() !== $this->submitted['prev_revision'] ) { |
200 | // This is a reasonably effective way to ensure prev revision matches, but for guarantees |
201 | // against race conditions there also exists a unique index on rev_prev_revision in mysql, |
202 | // meaning if someone else inserts against the parent we and the submitter think is the |
203 | // latest, our insert will fail. |
204 | // TODO: Catch whatever exception happens there, make sure the most recent revision is the |
205 | // one in the cache before handing user back to specific dialog indicating race condition |
206 | $this->addError( |
207 | 'prev_revision', |
208 | $this->context->msg( 'flow-error-prev-revision-mismatch' )->params( |
209 | $this->submitted['prev_revision'], |
210 | $topicTitle->getRevisionId()->getAlphadecimal(), |
211 | $this->context->getUser()->getName() |
212 | ), |
213 | [ 'revision_id' => $topicTitle->getRevisionId()->getAlphadecimal() ] // save current revision ID |
214 | ); |
215 | return; |
216 | } |
217 | |
218 | $this->newRevision = $topicTitle->newNextRevision( |
219 | $this->context->getUser(), |
220 | $this->submitted['content'], |
221 | 'topic-title-wikitext', |
222 | 'edit-title', |
223 | $this->workflow->getArticleTitle() |
224 | ); |
225 | if ( !$this->checkSpamFilters( $topicTitle, $this->newRevision ) ) { |
226 | return; |
227 | } |
228 | } |
229 | |
230 | protected function validateReply() { |
231 | if ( !isset( $this->submitted['content'] ) || trim( $this->submitted['content'] ) === '' ) { |
232 | $this->addError( 'content', $this->context->msg( 'flow-error-missing-content' ) ); |
233 | return; |
234 | } |
235 | if ( !isset( $this->submitted['replyTo'] ) ) { |
236 | $this->addError( 'replyTo', $this->context->msg( 'flow-error-missing-replyto' ) ); |
237 | return; |
238 | } |
239 | |
240 | $post = $this->loadRequestedPost( $this->submitted['replyTo'] ); |
241 | if ( !$post ) { |
242 | return; // loadRequestedPost adds its own errors |
243 | } |
244 | if ( !$this->permissions->isAllowed( $post, 'reply' ) ) { |
245 | $this->addError( 'permissions', $this->getDisallowedErrorMessage( $post ) ); |
246 | return; |
247 | } |
248 | $this->newRevision = $post->reply( |
249 | $this->workflow, |
250 | $this->context->getUser(), |
251 | $this->submitted['content'], |
252 | // default to wikitext when not specified, for old API requests |
253 | $this->submitted['format'] ?? 'wikitext' |
254 | ); |
255 | if ( !$this->checkSpamFilters( null, $this->newRevision ) ) { |
256 | return; |
257 | } |
258 | |
259 | $this->extraCommitMetadata['reply-to'] = $post; |
260 | } |
261 | |
262 | protected function validateModerateTopic() { |
263 | $root = $this->loadRootPost(); |
264 | if ( !$root ) { |
265 | return; |
266 | } |
267 | |
268 | $this->doModerate( $root ); |
269 | } |
270 | |
271 | protected function validateModeratePost() { |
272 | if ( empty( $this->submitted['postId'] ) ) { |
273 | $this->addError( 'post', $this->context->msg( 'flow-error-missing-postId' ) ); |
274 | return; |
275 | } |
276 | |
277 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
278 | $post = $this->loadRequestedPost( $this->submitted['postId'] ); |
279 | if ( !$post ) { |
280 | // loadRequestedPost added its own messages to $this->errors; |
281 | return; |
282 | } |
283 | if ( $post->isTopicTitle() ) { |
284 | $this->addError( 'moderate', $this->context->msg( 'flow-error-not-a-post' ) ); |
285 | return; |
286 | } |
287 | $this->doModerate( $post ); |
288 | } |
289 | |
290 | protected function doModerate( PostRevision $post ) { |
291 | if ( |
292 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
293 | $this->submitted['moderationState'] === AbstractRevision::MODERATED_LOCKED |
294 | && $post->isModerated() |
295 | ) { |
296 | $this->addError( 'moderate', $this->context->msg( 'flow-error-lock-moderated-post' ) ); |
297 | return; |
298 | } |
299 | |
300 | // Moderation state supplied in request parameters |
301 | $moderationState = $this->submitted['moderationState'] ?? null; |
302 | |
303 | // $moderationState should be a string like 'restore', 'suppress', etc. The exact strings allowed |
304 | // are checked below with $post->isValidModerationState(), but this is checked first otherwise |
305 | // a blank string would restore a post(due to AbstractRevision::MODERATED_NONE === ''). |
306 | if ( !$moderationState ) { |
307 | $this->addError( 'moderate', $this->context->msg( 'flow-error-invalid-moderation-state' ) ); |
308 | return; |
309 | } |
310 | |
311 | /* |
312 | * BC: 'suppress' used to be called 'censor', 'lock' was 'close' & |
313 | * 'unlock' was 'reopen' |
314 | */ |
315 | $bc = [ |
316 | 'censor' => AbstractRevision::MODERATED_SUPPRESSED, |
317 | 'close' => AbstractRevision::MODERATED_LOCKED, |
318 | 'reopen' => 'un' . AbstractRevision::MODERATED_LOCKED |
319 | ]; |
320 | $moderationState = str_replace( array_keys( $bc ), array_values( $bc ), $moderationState ); |
321 | |
322 | // these all just mean set to no moderation, it returns a post to unmoderated status |
323 | $allowedRestoreAliases = [ 'unlock', 'unhide', 'undelete', 'unsuppress', /* BC for unlock: */ 'reopen' ]; |
324 | if ( in_array( $moderationState, $allowedRestoreAliases ) ) { |
325 | $moderationState = 'restore'; |
326 | } |
327 | // By allowing the moderationState to be sourced from $this->submitted['moderationState'] |
328 | // we no longer have a unique action name for use with the permissions system. This rebuilds |
329 | // an action name. e.x. restore-post, restore-topic, suppress-topic, etc. |
330 | $action = $moderationState . ( $post->isTopicTitle() ? "-topic" : "-post" ); |
331 | |
332 | if ( $moderationState === 'restore' ) { |
333 | $newState = AbstractRevision::MODERATED_NONE; |
334 | } else { |
335 | $newState = $moderationState; |
336 | } |
337 | |
338 | if ( !$post->isValidModerationState( $newState ) ) { |
339 | $this->addError( 'moderate', $this->context->msg( 'flow-error-invalid-moderation-state' ) ); |
340 | return; |
341 | } |
342 | if ( !$this->permissions->isAllowed( $post, $action ) ) { |
343 | $this->addError( 'permissions', $this->getDisallowedErrorMessage( $post ) ); |
344 | return; |
345 | } |
346 | |
347 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
348 | if ( trim( $this->submitted['reason'] ) === '' ) { |
349 | $this->addError( 'moderate', $this->context->msg( 'flow-error-invalid-moderation-reason' ) ); |
350 | return; |
351 | } |
352 | |
353 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
354 | $reason = $this->submitted['reason']; |
355 | |
356 | $this->newRevision = $post->moderate( $this->context->getUser(), $newState, $action, $reason ); |
357 | if ( !$this->newRevision ) { |
358 | $this->addError( 'moderate', $this->context->msg( 'flow-error-not-allowed' ) ); |
359 | return; |
360 | } |
361 | } |
362 | |
363 | protected function validateEditPost() { |
364 | if ( empty( $this->submitted['postId'] ) ) { |
365 | $this->addError( 'post', $this->context->msg( 'flow-error-missing-postId' ) ); |
366 | return; |
367 | } |
368 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
369 | if ( trim( $this->submitted['content'] ) === '' ) { |
370 | $this->addError( 'content', $this->context->msg( 'flow-error-missing-content' ) ); |
371 | return; |
372 | } |
373 | if ( empty( $this->submitted['prev_revision'] ) ) { |
374 | $this->addError( 'prev_revision', $this->context->msg( 'flow-error-missing-prev-revision-identifier' ) ); |
375 | return; |
376 | } |
377 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
378 | $post = $this->loadRequestedPost( $this->submitted['postId'] ); |
379 | if ( !$post ) { |
380 | return; |
381 | } |
382 | if ( !$this->permissions->isAllowed( $post, 'edit-post' ) ) { |
383 | $this->addError( 'permissions', $this->getDisallowedErrorMessage( $post ) ); |
384 | return; |
385 | } |
386 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
387 | if ( $post->getRevisionId()->getAlphadecimal() !== $this->submitted['prev_revision'] ) { |
388 | // This is a reasonably effective way to ensure prev revision |
389 | // matches, but for guarantees against race conditions there |
390 | // also exists a unique index on rev_prev_revision in mysql, |
391 | // meaning if someone else inserts against the parent we and |
392 | // the submitter think is the latest, our insert will fail. |
393 | // TODO: Catch whatever exception happens there, make sure the |
394 | // most recent revision is the one in the cache before handing |
395 | // user back to specific dialog indicating race condition |
396 | $this->addError( |
397 | 'prev_revision', |
398 | $this->context->msg( 'flow-error-prev-revision-mismatch' )->params( |
399 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
400 | $this->submitted['prev_revision'], |
401 | $post->getRevisionId()->getAlphadecimal(), |
402 | $this->context->getUser()->getName() |
403 | ), |
404 | [ 'revision_id' => $post->getRevisionId()->getAlphadecimal() ] // save current revision ID |
405 | ); |
406 | return; |
407 | } |
408 | |
409 | $this->newRevision = $post->newNextRevision( |
410 | $this->context->getUser(), |
411 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
412 | $this->submitted['content'], |
413 | // default to wikitext when not specified, for old API requests |
414 | $this->submitted['format'] ?? 'wikitext', |
415 | 'edit-post', |
416 | $this->workflow->getArticleTitle() |
417 | ); |
418 | |
419 | if ( $this->newRevision->getRevisionId()->equals( $post->getRevisionId() ) ) { |
420 | $this->extraCommitMetadata['null-edit'] = true; |
421 | } elseif ( !$this->checkSpamFilters( $post, $this->newRevision ) ) { |
422 | return; |
423 | } |
424 | } |
425 | |
426 | public function commit() { |
427 | switch ( $this->action ) { |
428 | case 'edit-topic-summary': |
429 | // pseudo-action does not do anything, only includes data in api response |
430 | return []; |
431 | |
432 | case 'reply': |
433 | case 'moderate-topic': |
434 | case 'lock-topic': |
435 | case 'restore-post': |
436 | case 'moderate-post': |
437 | case 'edit-title': |
438 | case 'undo-edit-post': |
439 | case 'edit-post': |
440 | if ( $this->newRevision === null ) { |
441 | throw new FailCommitException( 'Attempt to save null revision', 'fail-commit' ); |
442 | } |
443 | |
444 | $metadata = $this->extraCommitMetadata + [ |
445 | 'workflow' => $this->workflow, |
446 | 'topic-title' => $this->loadTopicTitle(), |
447 | ]; |
448 | if ( !$metadata['topic-title'] instanceof PostRevision ) { |
449 | // permissions failure, should never have gotten this far |
450 | throw new PermissionException( 'Not Allowed', 'insufficient-permission' ); |
451 | } |
452 | if ( $this->newRevision->getPostId()->equals( $metadata['topic-title']->getPostId() ) ) { |
453 | // When performing actions against the topic-title self::loadTopicTitle |
454 | // returns the previous revision. |
455 | $metadata['topic-title'] = $this->newRevision; |
456 | } |
457 | |
458 | // store data, unless we're dealing with a null-edit (in which case |
459 | // is storing the same thing not only pointless, it can even be |
460 | // incorrect, since listeners will run & generate notifications etc) |
461 | if ( !isset( $this->extraCommitMetadata['null-edit'] ) ) { |
462 | $this->storage->put( $this->newRevision, $metadata ); |
463 | $this->workflow->updateLastUpdated( $this->newRevision->getRevisionId() ); |
464 | $this->storage->put( $this->workflow, $metadata ); |
465 | |
466 | if ( str_starts_with( $this->action, 'moderate-' ) ) { |
467 | $topicId = $this->newRevision->getCollection()->getRoot()->getId(); |
468 | |
469 | $moderate = $this->newRevision->isModerated() |
470 | && ( $this->newRevision->getModerationState() === PostRevision::MODERATED_DELETED |
471 | || $this->newRevision->getModerationState() === PostRevision::MODERATED_SUPPRESSED ); |
472 | |
473 | /** @var Controller $controller */ |
474 | $controller = Container::get( 'controller.notification' ); |
475 | if ( $this->action === 'moderate-topic' ) { |
476 | $controller->moderateTopicNotifications( $topicId, $moderate ); |
477 | } elseif ( $this->action === 'moderate-post' ) { |
478 | $postId = $this->newRevision->getPostId(); |
479 | $controller->moderatePostNotifications( $topicId, $postId, $moderate ); |
480 | } |
481 | } |
482 | } |
483 | |
484 | $newRevision = $this->newRevision; |
485 | |
486 | // If no context was loaded render the post in isolation |
487 | // @todo make more explicit |
488 | try { |
489 | $newRevision->getChildren(); |
490 | } catch ( DataModelException $e ) { |
491 | $newRevision->setChildren( [] ); |
492 | } |
493 | |
494 | $returnMetadata = [ |
495 | 'post-id' => $this->newRevision->getPostId(), |
496 | 'post-revision-id' => $this->newRevision->getRevisionId(), |
497 | ]; |
498 | |
499 | return $returnMetadata; |
500 | |
501 | default: |
502 | throw new InvalidActionException( "Unknown commit action: {$this->action}", 'invalid-action' ); |
503 | } |
504 | } |
505 | |
506 | public function renderApi( array $options ) { |
507 | $output = [ 'type' => $this->getName() ]; |
508 | |
509 | $topic = $this->loadTopicTitle(); |
510 | if ( !$topic ) { |
511 | return $output + $this->finalizeApiOutput( $options ); |
512 | } |
513 | |
514 | // there's probably some OO way to turn this stack of if/else into |
515 | // something nicer. Consider better ways before extending this with |
516 | // more conditionals |
517 | switch ( $this->action ) { |
518 | case 'history': |
519 | // single post history or full topic? |
520 | if ( isset( $options['postId'] ) ) { |
521 | // singular post history |
522 | $output += $this->renderPostHistoryApi( $options, UUID::create( $options['postId'] ) ); |
523 | } else { |
524 | // post history for full topic |
525 | $output += $this->renderTopicHistoryApi( $options ); |
526 | } |
527 | break; |
528 | |
529 | case 'single-view': |
530 | if ( isset( $options['revId'] ) ) { |
531 | $revId = $options['revId']; |
532 | } else { |
533 | throw new InvalidInputException( 'A revision must be provided', 'invalid-input' ); |
534 | } |
535 | $output += $this->renderSingleViewApi( $revId ); |
536 | break; |
537 | |
538 | case 'lock-topic': |
539 | // Treat topic as a post, only the post + summary are needed |
540 | $result = $this->renderPostApi( $options, $this->workflow->getId() ); |
541 | if ( $result !== null ) { |
542 | $topicId = $result['roots'][0]; |
543 | $revisionId = $result['posts'][$topicId][0]; |
544 | $output += $result['revisions'][$revisionId]; |
545 | } |
546 | break; |
547 | |
548 | case 'compare-post-revisions': |
549 | $output += $this->renderDiffViewApi( $options ); |
550 | break; |
551 | |
552 | case 'undo-edit-post': |
553 | $output += $this->renderUndoApi( $options ); |
554 | break; |
555 | |
556 | case 'view-post-history': |
557 | // View entire history of single post |
558 | $output += $this->renderPostHistoryApi( $options, UUID::create( $options['postId'] ), false ); |
559 | break; |
560 | |
561 | case 'view-topic-history': |
562 | // View entire history of a topic's posts |
563 | $output += $this->renderTopicHistoryApi( $options, false ); |
564 | break; |
565 | |
566 | // Any actions require (re)rendering the whole topic |
567 | case 'edit-post': |
568 | case 'moderate-post': |
569 | case 'restore-post': |
570 | case 'reply': |
571 | case 'moderate-topic': |
572 | case 'view-topic': |
573 | case 'view' && !isset( $options['postId'] ) && !isset( $options['revId'] ): |
574 | // view full topic |
575 | $output += $this->renderTopicApi( $options ); |
576 | break; |
577 | |
578 | case 'edit-title': |
579 | case 'view-post': |
580 | case 'view': |
581 | default: |
582 | // view single post, possibly specific revision |
583 | $result = $this->renderPostApi( $options ); |
584 | if ( $result !== null ) { |
585 | $output += $result; |
586 | } |
587 | break; |
588 | } |
589 | |
590 | return $output + $this->finalizeApiOutput( $options ); |
591 | } |
592 | |
593 | /** |
594 | * @param array $options |
595 | * @return array |
596 | */ |
597 | protected function finalizeApiOutput( $options ) { |
598 | if ( $this->wasSubmitted() ) { |
599 | // Failed actions, like reply, end up here |
600 | return [ |
601 | 'submitted' => $this->submitted, |
602 | 'errors' => $this->errors, |
603 | ]; |
604 | } else { |
605 | return [ |
606 | 'submitted' => $options, |
607 | 'errors' => $this->errors, |
608 | ]; |
609 | } |
610 | } |
611 | |
612 | /** |
613 | * @todo Duplicated logic in other diff view block |
614 | * @param array $options |
615 | * @return array |
616 | */ |
617 | protected function renderDiffViewApi( array $options ) { |
618 | if ( !isset( $options['newRevision'] ) ) { |
619 | throw new InvalidInputException( 'A revision must be provided for comparison', |
620 | 'revision-comparison' ); |
621 | } |
622 | $oldRevision = null; |
623 | if ( isset( $options['oldRevision'] ) ) { |
624 | $oldRevision = $options['oldRevision']; |
625 | } |
626 | [ $new, $old ] = Container::get( 'query.post.view' ) |
627 | ->getDiffViewResult( UUID::create( $options['newRevision'] ), UUID::create( $oldRevision ) ); |
628 | |
629 | return [ |
630 | 'revision' => Container::get( 'formatter.revision.diff.view' ) |
631 | ->formatApi( $new, $old, $this->context ) |
632 | ]; |
633 | } |
634 | |
635 | /** |
636 | * @todo Duplicated logic in other single view block |
637 | * @param int $revId |
638 | * @return array |
639 | */ |
640 | protected function renderSingleViewApi( $revId ) { |
641 | $row = Container::get( 'query.post.view' )->getSingleViewResult( $revId ); |
642 | |
643 | if ( !$this->permissions->isAllowed( $row->revision, 'view' ) ) { |
644 | $this->addError( 'permissions', $this->getDisallowedErrorMessage( $row->revision ) ); |
645 | return []; |
646 | } |
647 | |
648 | return [ |
649 | 'revision' => Container::get( 'formatter.revisionview' )->formatApi( $row, $this->context ) |
650 | ]; |
651 | } |
652 | |
653 | protected function renderTopicApi( array $options, $workflowId = '' ) { |
654 | $serializer = Container::get( 'formatter.topic' ); |
655 | $format = $options['format'] ?? 'fixed-html'; |
656 | $serializer->setContentFormat( $format ); |
657 | |
658 | if ( !$workflowId ) { |
659 | if ( $this->workflow->isNew() ) { |
660 | return $serializer->buildEmptyResult( $this->workflow ); |
661 | } |
662 | $workflowId = $this->workflow->getId(); |
663 | } |
664 | |
665 | if ( $this->submitted !== null ) { |
666 | $options += $this->submitted; |
667 | } |
668 | |
669 | // In the topic level responses we only want to force a single revision |
670 | // to wikitext (the one we're editing), not the entire thing. |
671 | if ( $this->action === 'edit-post' && !empty( $options['revId'] ) ) { |
672 | $uuid = UUID::create( $options['revId'] ); |
673 | if ( $uuid ) { |
674 | $serializer->setContentFormat( 'wikitext', $uuid ); |
675 | } |
676 | } |
677 | |
678 | return $serializer->formatApi( |
679 | $this->workflow, |
680 | Container::get( 'query.topiclist' )->getResults( [ $workflowId ] ), |
681 | $this->context |
682 | ); |
683 | } |
684 | |
685 | /** |
686 | * @todo Any failed action performed against a single revisions ends up here. |
687 | * To generate forms with validation errors in the non-javascript renders we |
688 | * need to add something to this output, but not sure what yet |
689 | * @param array $options |
690 | * @param string $postId |
691 | * @return null|array[] |
692 | * @throws FlowException |
693 | */ |
694 | protected function renderPostApi( array $options, $postId = '' ) { |
695 | if ( $this->workflow->isNew() ) { |
696 | throw new FlowException( 'No posts can exist for non-existent topic' ); |
697 | } |
698 | |
699 | $format = $options['format'] ?? 'fixed-html'; |
700 | $serializer = $this->getRevisionFormatter( $format ); |
701 | |
702 | if ( !$postId ) { |
703 | if ( isset( $options['postId'] ) ) { |
704 | $postId = $options['postId']; |
705 | } elseif ( $this->newRevision ) { |
706 | // API results after a reply will have no $postId (ID is not yet |
707 | // known when the reply is submitted) so we'll grab it from the |
708 | // newly added revision |
709 | $postId = $this->newRevision->getPostId(); |
710 | } else { |
711 | throw new FlowException( 'No post id specified' ); |
712 | } |
713 | } else { |
714 | // $postId is only set for lock-topic, which should default to |
715 | // wikitext instead of html |
716 | $format = $options['format'] ?? 'wikitext'; |
717 | $serializer->setContentFormat( $format, UUID::create( $postId ) ); |
718 | } |
719 | |
720 | $row = Container::get( 'query.singlepost' )->getResult( UUID::create( $postId ) ); |
721 | $serialized = $serializer->formatApi( $row, $this->context ); |
722 | if ( !$serialized ) { |
723 | return null; |
724 | } |
725 | |
726 | return [ |
727 | 'roots' => [ $serialized['postId'] ], |
728 | 'posts' => [ |
729 | $serialized['postId'] => [ $serialized['revisionId'] ], |
730 | ], |
731 | 'revisions' => [ |
732 | $serialized['revisionId'] => $serialized, |
733 | ] |
734 | ]; |
735 | } |
736 | |
737 | protected function renderUndoApi( array $options ) { |
738 | if ( $this->workflow->isNew() ) { |
739 | throw new FlowException( 'No posts can exist for non-existent topic' ); |
740 | } |
741 | |
742 | if ( !isset( $options['startId'] ) || !isset( $options['endId'] ) ) { |
743 | throw new InvalidInputException( 'Both startId and endId must be provided' ); |
744 | } |
745 | |
746 | /** @var RevisionViewQuery $query */ |
747 | $query = Container::get( 'query.post.view' ); |
748 | $rows = $query->getUndoDiffResult( $options['startId'], $options['endId'] ); |
749 | if ( !$rows ) { |
750 | throw new InvalidInputException( 'Could not load revision to undo' ); |
751 | } |
752 | |
753 | $serializer = Container::get( 'formatter.undoedit' ); |
754 | return $serializer->formatApi( $rows[0], $rows[1], $rows[2], $this->context ); |
755 | } |
756 | |
757 | /** |
758 | * @param string $format Content format (html|wikitext|fixed-html|topic-title-html|topic-title-wikitext) |
759 | * @return RevisionFormatter |
760 | */ |
761 | protected function getRevisionFormatter( $format ) { |
762 | $serializer = Container::get( 'formatter.revision.factory' )->create(); |
763 | $serializer->setContentFormat( $format ); |
764 | |
765 | return $serializer; |
766 | } |
767 | |
768 | protected function renderTopicHistoryApi( array $options, $navbar = true ) { |
769 | if ( $this->workflow->isNew() ) { |
770 | throw new FlowException( 'No topic history can exist for non-existent topic' ); |
771 | } |
772 | return $this->processHistoryResult( Container::get( 'query.topic.history' ), |
773 | $this->workflow->getId(), $options, $navbar ); |
774 | } |
775 | |
776 | protected function renderPostHistoryApi( array $options, UUID $postId, $navbar = true ) { |
777 | if ( $this->workflow->isNew() ) { |
778 | throw new FlowException( 'No post history can exist for non-existent topic' ); |
779 | } |
780 | return $this->processHistoryResult( Container::get( 'query.post.history' ), |
781 | $postId, $options, $navbar ); |
782 | } |
783 | |
784 | /** |
785 | * Process the history result for either topic or post |
786 | * |
787 | * @param TopicHistoryQuery|PostHistoryQuery $query |
788 | * @param UUID $uuid |
789 | * @param array $options |
790 | * @param bool $navbar Whether to include the page navbar |
791 | * @return array |
792 | */ |
793 | protected function processHistoryResult( |
794 | /* TopicHistoryQuery|PostHistoryQuery */ $query, |
795 | UUID $uuid, |
796 | $options, |
797 | $navbar = true |
798 | ) { |
799 | global $wgRequest; |
800 | |
801 | $format = $options['format'] ?? 'fixed-html'; |
802 | $serializer = $this->getRevisionFormatter( $format ); |
803 | $serializer->setIncludeHistoryProperties( true ); |
804 | |
805 | [ $limit, /* $offset */ ] = $wgRequest->getLimitOffsetForUser( |
806 | $this->context->getUser() |
807 | ); |
808 | // don't use offset from getLimitOffset - that assumes an int, which our |
809 | // UUIDs are not |
810 | $offset = $wgRequest->getText( 'offset' ); |
811 | $offset = $offset ? UUID::create( $offset ) : null; |
812 | |
813 | $pager = new HistoryPager( $query, $uuid ); |
814 | $pager->setLimit( $limit ); |
815 | $pager->setOffset( $offset ); |
816 | $pager->doQuery(); |
817 | $history = $pager->getResult(); |
818 | |
819 | $revisions = []; |
820 | foreach ( $history as $row ) { |
821 | // @phan-suppress-next-line PhanTypeMismatchArgument |
822 | $serialized = $serializer->formatApi( $row, $this->context, 'history' ); |
823 | // if the user is not allowed to see this row it will return empty |
824 | if ( $serialized ) { |
825 | $revisions[] = $serialized; |
826 | } |
827 | } |
828 | |
829 | $response = [ 'revisions' => $revisions ]; |
830 | if ( $navbar ) { |
831 | $response['navbar'] = $pager->getNavigationBar(); |
832 | } |
833 | return $response; |
834 | } |
835 | |
836 | /** |
837 | * @return PostRevision|null |
838 | */ |
839 | public function loadRootPost() { |
840 | if ( $this->root !== null ) { |
841 | return $this->root; |
842 | } |
843 | |
844 | $rootPost = $this->rootLoader->get( $this->workflow->getId() ); |
845 | |
846 | if ( $this->permissions->isAllowed( $rootPost, 'view' ) ) { |
847 | // topicTitle is same as root, difference is root has children populated to full depth |
848 | $this->topicTitle = $rootPost; |
849 | $this->root = $rootPost; |
850 | return $rootPost; |
851 | } |
852 | |
853 | $this->addError( 'moderation', $this->context->msg( 'flow-error-not-allowed' ) ); |
854 | |
855 | return null; |
856 | } |
857 | |
858 | /** |
859 | * @param string $action Permissions action to require to return revision |
860 | * @return AbstractRevision|null |
861 | * @throws InvalidDataException |
862 | */ |
863 | public function loadTopicTitle( $action = 'view' ) { |
864 | if ( $this->workflow->isNew() ) { |
865 | throw new InvalidDataException( 'New workflows do not have any related content', |
866 | 'missing-topic-title' ); |
867 | } |
868 | |
869 | if ( $this->topicTitle === null ) { |
870 | $found = $this->storage->find( |
871 | 'PostRevision', |
872 | [ 'rev_type_id' => $this->workflow->getId() ], |
873 | [ 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 ] |
874 | ); |
875 | if ( !$found ) { |
876 | throw new InvalidDataException( 'Every workflow must have an associated topic title', |
877 | 'missing-topic-title' ); |
878 | } |
879 | $this->topicTitle = reset( $found ); |
880 | |
881 | // this method loads only title, nothing else; otherwise, you're |
882 | // looking for loadRootPost |
883 | $this->topicTitle->setChildren( [] ); |
884 | $this->topicTitle->setDepth( 0 ); |
885 | $this->topicTitle->setRootPost( $this->topicTitle ); |
886 | } |
887 | |
888 | if ( !$this->permissions->isAllowed( $this->topicTitle, $action ) ) { |
889 | $this->addError( 'permissions', $this->getDisallowedErrorMessage( $this->topicTitle ) ); |
890 | return null; |
891 | } |
892 | |
893 | return $this->topicTitle; |
894 | } |
895 | |
896 | /** |
897 | * @todo Move this to AbstractBlock and use for summary/header/etc. |
898 | * @param AbstractRevision $revision |
899 | * @return Message |
900 | */ |
901 | protected function getDisallowedErrorMessage( AbstractRevision $revision ) { |
902 | if ( in_array( $this->action, [ 'moderate-topic', 'moderate-post' ] ) ) { |
903 | /* |
904 | * When failing to moderate an already moderated action (like |
905 | * undo), show the more general "you have insufficient |
906 | * permissions for this action" message, rather than the |
907 | * specialized "this topic is <hidden|deleted|suppressed>" msg. |
908 | */ |
909 | return $this->context->msg( 'flow-error-not-allowed' ); |
910 | } |
911 | |
912 | $state = $revision->getModerationState(); |
913 | |
914 | // display simple message |
915 | // i18n messages: |
916 | // flow-error-not-allowed-hide, |
917 | // flow-error-not-allowed-reply-to-hide-topic |
918 | // flow-error-not-allowed-delete |
919 | // flow-error-not-allowed-reply-to-delete-topic |
920 | // flow-error-not-allowed-suppress |
921 | // flow-error-not-allowed-reply-to-suppress-topic |
922 | if ( $revision instanceof PostRevision ) { |
923 | $type = $revision->isTopicTitle() ? 'topic' : 'post'; |
924 | } else { |
925 | $type = $revision->getRevisionType(); |
926 | } |
927 | |
928 | // Show a snippet of the relevant log entry if available. |
929 | if ( LogPage::isLogType( $state ) ) { |
930 | // check if user has sufficient permissions to see log |
931 | $logPage = new LogPage( $state ); |
932 | if ( MediaWikiServices::getInstance()->getPermissionManager() |
933 | ->userHasRight( $this->context->getUser(), $logPage->getRestriction() ) |
934 | ) { |
935 | // LogEventsList::showLogExtract will write to OutputPage, but we |
936 | // actually just want that text, to write it ourselves wherever we want, |
937 | // so let's create an OutputPage object to then get the content from. |
938 | $rc = new RequestContext(); |
939 | $output = $rc->getOutput(); |
940 | |
941 | // get log extract |
942 | $entries = LogEventsList::showLogExtract( |
943 | $output, |
944 | [ $state ], |
945 | $this->workflow->getArticleTitle()->getPrefixedText(), |
946 | '', |
947 | [ |
948 | 'lim' => 10, |
949 | 'showIfEmpty' => false, |
950 | // i18n messages: |
951 | // flow-error-not-allowed-hide-extract |
952 | // flow-error-not-allowed-reply-to-hide-topic-extract |
953 | // flow-error-not-allowed-delete-extract |
954 | // flow-error-not-allowed-reply-to-delete-topic-extract |
955 | // flow-error-not-allowed-suppress-extract |
956 | // flow-error-not-allowed-reply-to-suppress-topic-extract |
957 | 'msgKey' => [ |
958 | [ |
959 | "flow-error-not-allowed-{$this->action}-to-$state-$type", |
960 | "flow-error-not-allowed-$state-extract", |
961 | ], |
962 | ] |
963 | ] |
964 | ); |
965 | |
966 | // check if there were any log extracts |
967 | if ( $entries ) { |
968 | $message = new RawMessage( '$1' ); |
969 | return $message->rawParams( $output->getHTML() ); |
970 | } |
971 | } |
972 | } |
973 | |
974 | return $this->context->msg( [ |
975 | // set of keys to try in order |
976 | "flow-error-not-allowed-{$this->action}-to-$state-$type", |
977 | "flow-error-not-allowed-$state", |
978 | "flow-error-not-allowed" |
979 | ] ); |
980 | } |
981 | |
982 | /** |
983 | * Loads the post referenced by $postId. Returns null when: |
984 | * $postId does not belong to the workflow |
985 | * The user does not have view access to the topic title |
986 | * The user does not have view access to the referenced post |
987 | * All these conditions add a relevant error message to $this->errors when returning null |
988 | * |
989 | * @param UUID|string $postId The post being requested |
990 | * @return PostRevision|null |
991 | */ |
992 | protected function loadRequestedPost( $postId ) { |
993 | if ( !$postId instanceof UUID ) { |
994 | $postId = UUID::create( $postId ); |
995 | } |
996 | '@phan-var UUID $postId'; |
997 | |
998 | if ( $this->rootLoader === null ) { |
999 | // Since there is no root loader the full tree is already loaded |
1000 | $topicTitle = $root = $this->loadRootPost(); |
1001 | if ( !$topicTitle ) { |
1002 | return null; |
1003 | } |
1004 | $post = $root->getDescendant( $postId ); |
1005 | if ( $post === null ) { |
1006 | // The requested postId is not a member of the current workflow |
1007 | $this->addError( 'post', $this->context->msg( |
1008 | 'flow-error-invalid-postId', $postId->getAlphadecimal() ) ); |
1009 | return null; |
1010 | } |
1011 | } else { |
1012 | // Load the post and its root |
1013 | $found = $this->rootLoader->getWithRoot( $postId ); |
1014 | if ( !$found['post'] || !$found['root'] || |
1015 | !$found['root']->getPostId()->equals( $this->workflow->getId() ) |
1016 | ) { |
1017 | $this->addError( 'post', $this->context->msg( |
1018 | 'flow-error-invalid-postId', $postId->getAlphadecimal() ) ); |
1019 | return null; |
1020 | } |
1021 | $this->topicTitle = $topicTitle = $found['root']; |
1022 | $post = $found['post']; |
1023 | |
1024 | // using the path to the root post, we can know the post's depth |
1025 | $rootPath = $this->rootLoader->getTreeRepo()->findRootPath( $postId ); |
1026 | $post->setDepth( count( $rootPath ) - 1 ); |
1027 | $post->setRootPost( $found['root'] ); |
1028 | } |
1029 | |
1030 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable |
1031 | if ( $this->permissions->isAllowed( $topicTitle, 'view' ) |
1032 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable |
1033 | && $this->permissions->isAllowed( $post, 'view' ) ) { |
1034 | return $post; |
1035 | } |
1036 | |
1037 | $this->addError( 'moderation', $this->context->msg( 'flow-error-not-allowed' ) ); |
1038 | return null; |
1039 | } |
1040 | |
1041 | /** |
1042 | * The prefix used for form data$pos |
1043 | * @return string |
1044 | */ |
1045 | public function getName() { |
1046 | return 'topic'; |
1047 | } |
1048 | |
1049 | /** |
1050 | * @param OutputPage $out |
1051 | * |
1052 | * @todo Provide more informative page title for actions other than view, |
1053 | * e.g. "Hide post in <TITLE>", "Unlock <TITLE>", etc. |
1054 | */ |
1055 | public function setPageTitle( OutputPage $out ) { |
1056 | $topic = $this->loadTopicTitle( $this->action === 'history' ? 'history' : 'view' ); |
1057 | if ( !$topic ) { |
1058 | return; |
1059 | } |
1060 | |
1061 | $title = $this->workflow->getOwnerTitle(); |
1062 | $convertedTitle = Utils::getConvertedTitle( $title ); |
1063 | $out->setPageTitleMsg( $out->msg( 'flow-topic-first-heading', $convertedTitle ) ); |
1064 | if ( $this->permissions->isAllowed( $topic, 'view' ) ) { |
1065 | if ( $this->action === 'undo-edit-post' ) { |
1066 | $key = 'flow-undo-edit-post'; |
1067 | } else { |
1068 | $key = 'flow-topic-html-title'; |
1069 | } |
1070 | $out->setHTMLTitle( $out->msg( $key, |
1071 | // This must be a rawParam to not expand {{foo}} in the title, it must |
1072 | // not be htmlspecialchar'd because OutputPage::setHtmlTitle handles that. |
1073 | Message::rawParam( $topic->getContent( 'topic-title-plaintext' ) ), |
1074 | $convertedTitle |
1075 | ) ); |
1076 | } else { |
1077 | $out->setHTMLTitle( $convertedTitle ); |
1078 | } |
1079 | $out->setSubtitle( '< ' . |
1080 | MediaWikiServices::getInstance()->getLinkRenderer()->makeLink( $title ) ); |
1081 | } |
1082 | } |