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
29066.69
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
4.94
 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\Context\RequestContext;
27use MediaWiki\Language\RawMessage;
28use MediaWiki\Logging\LogEventsList;
29use MediaWiki\Logging\LogPage;
30use MediaWiki\MediaWikiServices;
31use MediaWiki\Message\Message;
32use MediaWiki\Output\OutputPage;
33
34class 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( '&lt; ' .
1080            MediaWikiServices::getInstance()->getLinkRenderer()->makeLink( $title ) );
1081    }
1082}