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