Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.78% covered (danger)
0.78%
4 / 511
0.00% covered (danger)
0.00%
0 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
TopicBlock
0.78% covered (danger)
0.78%
4 / 511
0.00% covered (danger)
0.00%
0 / 26
29404.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
7.46
 validate
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
156
 validateEditTitle
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
132
 validateReply
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 validateModerateTopic
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 validateModeratePost
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 doModerate
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
132
 validateEditPost
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
90
 commit
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
462
 renderApi
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
702
 finalizeApiOutput
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 renderDiffViewApi
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 renderSingleViewApi
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 renderTopicApi
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 renderPostApi
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
42
 renderUndoApi
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 getRevisionFormatter
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 renderTopicHistoryApi
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 renderPostHistoryApi
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 processHistoryResult
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
 loadRootPost
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 loadTopicTitle
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 getDisallowedErrorMessage
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
56
 loadRequestedPost
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
110
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setPageTitle
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace Flow\Block;
4
5use Flow\Container;
6use Flow\Conversion\Utils;
7use Flow\Data\ManagerGroup;
8use Flow\Data\Pager\HistoryPager;
9use Flow\Exception\DataModelException;
10use Flow\Exception\FailCommitException;
11use Flow\Exception\FlowException;
12use Flow\Exception\InvalidActionException;
13use Flow\Exception\InvalidDataException;
14use Flow\Exception\InvalidInputException;
15use Flow\Exception\PermissionException;
16use Flow\Formatter\PostHistoryQuery;
17use Flow\Formatter\RevisionFormatter;
18use Flow\Formatter\RevisionViewQuery;
19use Flow\Formatter\TopicHistoryQuery;
20use Flow\Model\AbstractRevision;
21use Flow\Model\PostRevision;
22use Flow\Model\UUID;
23use Flow\Model\Workflow;
24use Flow\Notifications\Controller;
25use Flow\Repository\RootPostLoader;
26use MediaWiki\Language\RawMessage;
27use MediaWiki\MediaWikiServices;
28use MediaWiki\Output\OutputPage;
29use Message;
30
31class 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( '&lt; ' .
1077            MediaWikiServices::getInstance()->getLinkRenderer()->makeLink( $title ) );
1078    }
1079}