wikimedia/mediawiki-extensions-Flow

View on GitHub
includes/Block/TopicBlock.php

Summary

Maintainability
F
1 wk
Test Coverage
<?php

namespace Flow\Block;

use Flow\Container;
use Flow\Conversion\Utils;
use Flow\Data\ManagerGroup;
use Flow\Data\Pager\HistoryPager;
use Flow\Exception\DataModelException;
use Flow\Exception\FailCommitException;
use Flow\Exception\FlowException;
use Flow\Exception\InvalidActionException;
use Flow\Exception\InvalidDataException;
use Flow\Exception\InvalidInputException;
use Flow\Exception\PermissionException;
use Flow\Formatter\PostHistoryQuery;
use Flow\Formatter\RevisionFormatter;
use Flow\Formatter\RevisionViewQuery;
use Flow\Formatter\TopicHistoryQuery;
use Flow\Model\AbstractRevision;
use Flow\Model\PostRevision;
use Flow\Model\UUID;
use Flow\Model\Workflow;
use Flow\Notifications\Controller;
use Flow\Repository\RootPostLoader;
use MediaWiki\Language\RawMessage;
use MediaWiki\MediaWikiServices;
use MediaWiki\Output\OutputPage;
use Message;

class TopicBlock extends AbstractBlock {

    /**
     * @var PostRevision|null
     */
    protected $root;

    /**
     * @var PostRevision|null
     */
    protected $topicTitle;

    /**
     * @var RootPostLoader|null
     */
    protected $rootLoader;

    /**
     * @var PostRevision|null
     */
    protected $newRevision;

    /**
     * @var array
     */
    protected $requestedPost = [];

    /**
     * @var array Map of data to be passed on as
     *  commit metadata for event handlers
     */
    protected $extraCommitMetadata = [];

    /** @inheritDoc */
    protected $supportedPostActions = [
        // Standard editing
        'edit-post', 'reply',
        // Moderation
        'moderate-topic',
        'moderate-post',
        // lock or unlock topic
        'lock-topic',
        // Other stuff
        'edit-title',
        'undo-edit-post',
    ];

    /** @inheritDoc */
    protected $supportedGetActions = [
        'reply', 'view', 'history', 'edit-post', 'edit-title', 'compare-post-revisions', 'single-view',
        'view-topic', 'view-topic-history', 'view-post', 'view-post-history', 'undo-edit-post',
        'moderate-topic', 'moderate-post', 'lock-topic',
    ];

    /**
     * @var string[]
     * @todo Fill in the template names
     */
    protected $templates = [
        'single-view' => 'single_view',
        'view' => '',
        'reply' => '',
        'history' => 'history',
        'edit-post' => '',
        'undo-edit-post' => 'undo_edit',
        'edit-title' => 'edit_title',
        'compare-post-revisions' => 'diff_view',
        'moderate-topic' => 'moderate_topic',
        'moderate-post' => 'moderate_post',
        'lock-topic' => 'lock',
    ];

    public function __construct( Workflow $workflow, ManagerGroup $storage, $root ) {
        parent::__construct( $workflow, $storage );
        if ( $root instanceof PostRevision ) {
            $this->root = $root;
        } elseif ( $root instanceof RootPostLoader ) {
            $this->rootLoader = $root;
        } else {
            throw new DataModelException(
                'Expected PostRevision or RootPostLoader, received: ' .
                    ( is_object( $root ) ? get_class( $root ) : gettype( $root ) ),
                'invalid-input'
            );
        }
    }

    protected function validate() {
        $topicTitle = $this->loadTopicTitle();
        if ( !$topicTitle ) {
            // permissions issue, self::loadTopicTitle should have added appropriate
            // error messages already.
            return;
        }

        switch ( $this->action ) {
            case 'edit-title':
                $this->validateEditTitle();
                break;

            case 'reply':
                $this->validateReply();
                break;

            case 'moderate-topic':
            case 'lock-topic':
                $this->validateModerateTopic();
                break;

            case 'moderate-post':
                $this->validateModeratePost();
                break;

            case 'restore-post':
                // @todo still necessary?
                $this->validateModeratePost();
                break;

            case 'undo-edit-post':
            case 'edit-post':
                $this->validateEditPost();
                break;

            case 'edit-topic-summary':
                // pseudo-action does not do anything, only includes data in api response
                break;

            default:
                throw new InvalidActionException( "Unexpected action: {$this->action}", 'invalid-action' );
        }
    }

    protected function validateEditTitle() {
        if ( $this->workflow->isNew() ) {
            $this->addError( 'content', $this->context->msg( 'flow-error-no-existing-workflow' ) );
            return;
        }
        if ( !isset( $this->submitted['content'] ) || !is_string( $this->submitted['content'] ) ) {
            $this->addError( 'content', $this->context->msg( 'flow-error-missing-title' ) );
            return;
        }
        $this->submitted['content'] = trim( $this->submitted['content'] );
        $len = mb_strlen( $this->submitted['content'] );
        if ( $len === 0 ) {
            $this->addError( 'content', $this->context->msg( 'flow-error-missing-title' ) );
            return;
        }
        if ( $len > PostRevision::MAX_TOPIC_LENGTH ) {
            $this->addError( 'content', $this->context->msg(
                'flow-error-title-too-long', PostRevision::MAX_TOPIC_LENGTH ) );
            return;
        }
        if ( empty( $this->submitted['prev_revision'] ) ) {
            $this->addError( 'prev_revision', $this->context->msg(
                'flow-error-missing-prev-revision-identifier' ) );
            return;
        }
        $topicTitle = $this->loadTopicTitle();
        if ( !$topicTitle ) {
            return;
        }
        if ( !$this->permissions->isAllowed( $topicTitle, 'edit-title' ) ) {
            $this->addError( 'permissions', $this->getDisallowedErrorMessage( $topicTitle ) );
            return;
        }
        if ( $topicTitle->getRevisionId()->getAlphadecimal() !== $this->submitted['prev_revision'] ) {
            // This is a reasonably effective way to ensure prev revision matches, but for guarantees
            // against race conditions there also exists a unique index on rev_prev_revision in mysql,
            // meaning if someone else inserts against the parent we and the submitter think is the
            // latest, our insert will fail.
            // TODO: Catch whatever exception happens there, make sure the most recent revision is the
            // one in the cache before handing user back to specific dialog indicating race condition
            $this->addError(
                'prev_revision',
                $this->context->msg( 'flow-error-prev-revision-mismatch' )->params(
                    $this->submitted['prev_revision'],
                    $topicTitle->getRevisionId()->getAlphadecimal(),
                    $this->context->getUser()->getName()
                ),
                [ 'revision_id' => $topicTitle->getRevisionId()->getAlphadecimal() ] // save current revision ID
            );
            return;
        }

        $this->newRevision = $topicTitle->newNextRevision(
            $this->context->getUser(),
            $this->submitted['content'],
            'topic-title-wikitext',
            'edit-title',
            $this->workflow->getArticleTitle()
        );
        if ( !$this->checkSpamFilters( $topicTitle, $this->newRevision ) ) {
            return;
        }
    }

    protected function validateReply() {
        if ( !isset( $this->submitted['content'] ) || trim( $this->submitted['content'] ) === '' ) {
            $this->addError( 'content', $this->context->msg( 'flow-error-missing-content' ) );
            return;
        }
        if ( !isset( $this->submitted['replyTo'] ) ) {
            $this->addError( 'replyTo', $this->context->msg( 'flow-error-missing-replyto' ) );
            return;
        }

        $post = $this->loadRequestedPost( $this->submitted['replyTo'] );
        if ( !$post ) {
            return; // loadRequestedPost adds its own errors
        }
        if ( !$this->permissions->isAllowed( $post, 'reply' ) ) {
            $this->addError( 'permissions', $this->getDisallowedErrorMessage( $post ) );
            return;
        }
        $this->newRevision = $post->reply(
            $this->workflow,
            $this->context->getUser(),
            $this->submitted['content'],
            // default to wikitext when not specified, for old API requests
            $this->submitted['format'] ?? 'wikitext'
        );
        if ( !$this->checkSpamFilters( null, $this->newRevision ) ) {
            return;
        }

        $this->extraCommitMetadata['reply-to'] = $post;
    }

    protected function validateModerateTopic() {
        $root = $this->loadRootPost();
        if ( !$root ) {
            return;
        }

        $this->doModerate( $root );
    }

    protected function validateModeratePost() {
        if ( empty( $this->submitted['postId'] ) ) {
            $this->addError( 'post', $this->context->msg( 'flow-error-missing-postId' ) );
            return;
        }

        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
        $post = $this->loadRequestedPost( $this->submitted['postId'] );
        if ( !$post ) {
            // loadRequestedPost added its own messages to $this->errors;
            return;
        }
        if ( $post->isTopicTitle() ) {
            $this->addError( 'moderate', $this->context->msg( 'flow-error-not-a-post' ) );
            return;
        }
        $this->doModerate( $post );
    }

    protected function doModerate( PostRevision $post ) {
        if (
            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
            $this->submitted['moderationState'] === AbstractRevision::MODERATED_LOCKED
            && $post->isModerated()
        ) {
            $this->addError( 'moderate', $this->context->msg( 'flow-error-lock-moderated-post' ) );
            return;
        }

        // Moderation state supplied in request parameters
        $moderationState = $this->submitted['moderationState'] ?? null;

        // $moderationState should be a string like 'restore', 'suppress', etc.  The exact strings allowed
        // are checked below with $post->isValidModerationState(), but this is checked first otherwise
        // a blank string would restore a post(due to AbstractRevision::MODERATED_NONE === '').
        if ( !$moderationState ) {
            $this->addError( 'moderate', $this->context->msg( 'flow-error-invalid-moderation-state' ) );
            return;
        }

        /*
         * BC: 'suppress' used to be called 'censor', 'lock' was 'close' &
         * 'unlock' was 'reopen'
         */
        $bc = [
            'censor' => AbstractRevision::MODERATED_SUPPRESSED,
            'close' => AbstractRevision::MODERATED_LOCKED,
            'reopen' => 'un' . AbstractRevision::MODERATED_LOCKED
        ];
        $moderationState = str_replace( array_keys( $bc ), array_values( $bc ), $moderationState );

        // these all just mean set to no moderation, it returns a post to unmoderated status
        $allowedRestoreAliases = [ 'unlock', 'unhide', 'undelete', 'unsuppress', /* BC for unlock: */ 'reopen' ];
        if ( in_array( $moderationState, $allowedRestoreAliases ) ) {
            $moderationState = 'restore';
        }
        // By allowing the moderationState to be sourced from $this->submitted['moderationState']
        // we no longer have a unique action name for use with the permissions system.  This rebuilds
        // an action name. e.x. restore-post, restore-topic, suppress-topic, etc.
        $action = $moderationState . ( $post->isTopicTitle() ? "-topic" : "-post" );

        if ( $moderationState === 'restore' ) {
            $newState = AbstractRevision::MODERATED_NONE;
        } else {
            $newState = $moderationState;
        }

        if ( !$post->isValidModerationState( $newState ) ) {
            $this->addError( 'moderate', $this->context->msg( 'flow-error-invalid-moderation-state' ) );
            return;
        }
        if ( !$this->permissions->isAllowed( $post, $action ) ) {
            $this->addError( 'permissions', $this->getDisallowedErrorMessage( $post ) );
            return;
        }

        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
        if ( trim( $this->submitted['reason'] ) === '' ) {
            $this->addError( 'moderate', $this->context->msg( 'flow-error-invalid-moderation-reason' ) );
            return;
        }

        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
        $reason = $this->submitted['reason'];

        $this->newRevision = $post->moderate( $this->context->getUser(), $newState, $action, $reason );
        if ( !$this->newRevision ) {
            $this->addError( 'moderate', $this->context->msg( 'flow-error-not-allowed' ) );
            return;
        }
    }

    protected function validateEditPost() {
        if ( empty( $this->submitted['postId'] ) ) {
            $this->addError( 'post', $this->context->msg( 'flow-error-missing-postId' ) );
            return;
        }
        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
        if ( trim( $this->submitted['content'] ) === '' ) {
            $this->addError( 'content', $this->context->msg( 'flow-error-missing-content' ) );
            return;
        }
        if ( empty( $this->submitted['prev_revision'] ) ) {
            $this->addError( 'prev_revision', $this->context->msg( 'flow-error-missing-prev-revision-identifier' ) );
            return;
        }
        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
        $post = $this->loadRequestedPost( $this->submitted['postId'] );
        if ( !$post ) {
            return;
        }
        if ( !$this->permissions->isAllowed( $post, 'edit-post' ) ) {
            $this->addError( 'permissions', $this->getDisallowedErrorMessage( $post ) );
            return;
        }
        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
        if ( $post->getRevisionId()->getAlphadecimal() !== $this->submitted['prev_revision'] ) {
            // This is a reasonably effective way to ensure prev revision
            // matches, but for guarantees against race conditions there
            // also exists a unique index on rev_prev_revision in mysql,
            // meaning if someone else inserts against the parent we and
            // the submitter think is the latest, our insert will fail.
            // TODO: Catch whatever exception happens there, make sure the
            // most recent revision is the one in the cache before handing
            // user back to specific dialog indicating race condition
            $this->addError(
                'prev_revision',
                $this->context->msg( 'flow-error-prev-revision-mismatch' )->params(
                    // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
                    $this->submitted['prev_revision'],
                    $post->getRevisionId()->getAlphadecimal(),
                    $this->context->getUser()->getName()
                ),
                [ 'revision_id' => $post->getRevisionId()->getAlphadecimal() ] // save current revision ID
            );
            return;
        }

        $this->newRevision = $post->newNextRevision(
            $this->context->getUser(),
            // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
            $this->submitted['content'],
            // default to wikitext when not specified, for old API requests
            $this->submitted['format'] ?? 'wikitext',
            'edit-post',
            $this->workflow->getArticleTitle()
        );

        if ( $this->newRevision->getRevisionId()->equals( $post->getRevisionId() ) ) {
            $this->extraCommitMetadata['null-edit'] = true;
        } elseif ( !$this->checkSpamFilters( $post, $this->newRevision ) ) {
            return;
        }
    }

    public function commit() {
        switch ( $this->action ) {
            case 'edit-topic-summary':
            // pseudo-action does not do anything, only includes data in api response
                return [];

            case 'reply':
            case 'moderate-topic':
            case 'lock-topic':
            case 'restore-post':
            case 'moderate-post':
            case 'edit-title':
            case 'undo-edit-post':
            case 'edit-post':
                if ( $this->newRevision === null ) {
                    throw new FailCommitException( 'Attempt to save null revision', 'fail-commit' );
                }

                $metadata = $this->extraCommitMetadata + [
                    'workflow' => $this->workflow,
                    'topic-title' => $this->loadTopicTitle(),
                ];
                if ( !$metadata['topic-title'] instanceof PostRevision ) {
                    // permissions failure, should never have gotten this far
                    throw new PermissionException( 'Not Allowed', 'insufficient-permission' );
                }
                if ( $this->newRevision->getPostId()->equals( $metadata['topic-title']->getPostId() ) ) {
                    // When performing actions against the topic-title self::loadTopicTitle
                    // returns the previous revision.
                    $metadata['topic-title'] = $this->newRevision;
                }

                // store data, unless we're dealing with a null-edit (in which case
                // is storing the same thing not only pointless, it can even be
                // incorrect, since listeners will run & generate notifications etc)
                if ( !isset( $this->extraCommitMetadata['null-edit'] ) ) {
                    $this->storage->put( $this->newRevision, $metadata );
                    $this->workflow->updateLastUpdated( $this->newRevision->getRevisionId() );
                    $this->storage->put( $this->workflow, $metadata );

                    if ( strpos( $this->action, 'moderate-' ) === 0 ) {
                        $topicId = $this->newRevision->getCollection()->getRoot()->getId();

                        $moderate = $this->newRevision->isModerated()
                            && ( $this->newRevision->getModerationState() === PostRevision::MODERATED_DELETED
                                || $this->newRevision->getModerationState() === PostRevision::MODERATED_SUPPRESSED );

                        /** @var Controller $controller */
                        $controller = Container::get( 'controller.notification' );
                        if ( $this->action === 'moderate-topic' ) {
                            $controller->moderateTopicNotifications( $topicId, $moderate );
                        } elseif ( $this->action === 'moderate-post' ) {
                            $postId = $this->newRevision->getPostId();
                            $controller->moderatePostNotifications( $topicId, $postId, $moderate );
                        }
                    }
                }

                $newRevision = $this->newRevision;

                // If no context was loaded render the post in isolation
                // @todo make more explicit
                try {
                    $newRevision->getChildren();
                } catch ( DataModelException $e ) {
                    $newRevision->setChildren( [] );
                }

                $returnMetadata = [
                    'post-id' => $this->newRevision->getPostId(),
                    'post-revision-id' => $this->newRevision->getRevisionId(),
                ];

                return $returnMetadata;

            default:
                throw new InvalidActionException( "Unknown commit action: {$this->action}", 'invalid-action' );
        }
    }

    public function renderApi( array $options ) {
        $output = [ 'type' => $this->getName() ];

        $topic = $this->loadTopicTitle();
        if ( !$topic ) {
            return $output + $this->finalizeApiOutput( $options );
        }

        // there's probably some OO way to turn this stack of if/else into
        // something nicer. Consider better ways before extending this with
        // more conditionals
        switch ( $this->action ) {
            case 'history':
                // single post history or full topic?
                if ( isset( $options['postId'] ) ) {
                    // singular post history
                    $output += $this->renderPostHistoryApi( $options, UUID::create( $options['postId'] ) );
                } else {
                    // post history for full topic
                    $output += $this->renderTopicHistoryApi( $options );
                }
                break;

            case 'single-view':
                if ( isset( $options['revId'] ) ) {
                    $revId = $options['revId'];
                } else {
                    throw new InvalidInputException( 'A revision must be provided', 'invalid-input' );
                }
                $output += $this->renderSingleViewApi( $revId );
                break;

            case 'lock-topic':
                // Treat topic as a post, only the post + summary are needed
                $result = $this->renderPostApi( $options, $this->workflow->getId() );
                if ( $result !== null ) {
                    $topicId = $result['roots'][0];
                    $revisionId = $result['posts'][$topicId][0];
                    $output += $result['revisions'][$revisionId];
                }
                break;

            case 'compare-post-revisions':
                $output += $this->renderDiffViewApi( $options );
                break;

            case 'undo-edit-post':
                $output += $this->renderUndoApi( $options );
                break;

            case 'view-post-history':
                // View entire history of single post
                $output += $this->renderPostHistoryApi( $options, UUID::create( $options['postId'] ), false );
                break;

            case 'view-topic-history':
                // View entire history of a topic's posts
                $output += $this->renderTopicHistoryApi( $options, false );
                break;

            // Any actions require (re)rendering the whole topic
            case 'edit-post':
            case 'moderate-post':
            case 'restore-post':
            case 'reply':
            case 'moderate-topic':
            case 'view-topic':
            case 'view' && !isset( $options['postId'] ) && !isset( $options['revId'] ):
                // view full topic
                $output += $this->renderTopicApi( $options );
                break;

            case 'edit-title':
            case 'view-post':
            case 'view':
            default:
                // view single post, possibly specific revision
                $result = $this->renderPostApi( $options );
                if ( $result !== null ) {
                    $output += $result;
                }
                break;
        }

        return $output + $this->finalizeApiOutput( $options );
    }

    /**
     * @param array $options
     * @return array
     */
    protected function finalizeApiOutput( $options ) {
        if ( $this->wasSubmitted() ) {
            // Failed actions, like reply, end up here
            return [
                'submitted' => $this->submitted,
                'errors' => $this->errors,
            ];
        } else {
            return [
                'submitted' => $options,
                'errors' => $this->errors,
            ];
        }
    }

    /**
     * @todo Duplicated logic in other diff view block
     * @param array $options
     * @return array
     */
    protected function renderDiffViewApi( array $options ) {
        if ( !isset( $options['newRevision'] ) ) {
            throw new InvalidInputException( 'A revision must be provided for comparison',
                'revision-comparison' );
        }
        $oldRevision = null;
        if ( isset( $options['oldRevision'] ) ) {
            $oldRevision = $options['oldRevision'];
        }
        [ $new, $old ] = Container::get( 'query.post.view' )
            ->getDiffViewResult( UUID::create( $options['newRevision'] ), UUID::create( $oldRevision ) );

        return [
            'revision' => Container::get( 'formatter.revision.diff.view' )
                ->formatApi( $new, $old, $this->context )
        ];
    }

    /**
     * @todo Duplicated logic in other single view block
     * @param int $revId
     * @return array
     */
    protected function renderSingleViewApi( $revId ) {
        $row = Container::get( 'query.post.view' )->getSingleViewResult( $revId );

        if ( !$this->permissions->isAllowed( $row->revision, 'view' ) ) {
            $this->addError( 'permissions', $this->getDisallowedErrorMessage( $row->revision ) );
            return [];
        }

        return [
            'revision' => Container::get( 'formatter.revisionview' )->formatApi( $row, $this->context )
        ];
    }

    protected function renderTopicApi( array $options, $workflowId = '' ) {
        $serializer = Container::get( 'formatter.topic' );
        $format = $options['format'] ?? 'fixed-html';
        $serializer->setContentFormat( $format );

        if ( !$workflowId ) {
            if ( $this->workflow->isNew() ) {
                return $serializer->buildEmptyResult( $this->workflow );
            }
            $workflowId = $this->workflow->getId();
        }

        if ( $this->submitted !== null ) {
            $options += $this->submitted;
        }

        // In the topic level responses we only want to force a single revision
        // to wikitext (the one we're editing), not the entire thing.
        if ( $this->action === 'edit-post' && !empty( $options['revId'] ) ) {
            $uuid = UUID::create( $options['revId'] );
            if ( $uuid ) {
                $serializer->setContentFormat( 'wikitext', $uuid );
            }
        }

        return $serializer->formatApi(
            $this->workflow,
            Container::get( 'query.topiclist' )->getResults( [ $workflowId ] ),
            $this->context
        );
    }

    /**
     * @todo Any failed action performed against a single revisions ends up here.
     * To generate forms with validation errors in the non-javascript renders we
     * need to add something to this output, but not sure what yet
     * @param array $options
     * @param string $postId
     * @return null|array[]
     * @throws FlowException
     */
    protected function renderPostApi( array $options, $postId = '' ) {
        if ( $this->workflow->isNew() ) {
            throw new FlowException( 'No posts can exist for non-existent topic' );
        }

        $format = $options['format'] ?? 'fixed-html';
        $serializer = $this->getRevisionFormatter( $format );

        if ( !$postId ) {
            if ( isset( $options['postId'] ) ) {
                $postId = $options['postId'];
            } elseif ( $this->newRevision ) {
                // API results after a reply will have no $postId (ID is not yet
                // known when the reply is submitted) so we'll grab it from the
                // newly added revision
                $postId = $this->newRevision->getPostId();
            } else {
                throw new FlowException( 'No post id specified' );
            }
        } else {
            // $postId is only set for lock-topic, which should default to
            // wikitext instead of html
            $format = $options['format'] ?? 'wikitext';
            $serializer->setContentFormat( $format, UUID::create( $postId ) );
        }

        $row = Container::get( 'query.singlepost' )->getResult( UUID::create( $postId ) );
        $serialized = $serializer->formatApi( $row, $this->context );
        if ( !$serialized ) {
            return null;
        }

        return [
            'roots' => [ $serialized['postId'] ],
            'posts' => [
                $serialized['postId'] => [ $serialized['revisionId'] ],
            ],
            'revisions' => [
                $serialized['revisionId'] => $serialized,
            ]
        ];
    }

    protected function renderUndoApi( array $options ) {
        if ( $this->workflow->isNew() ) {
            throw new FlowException( 'No posts can exist for non-existent topic' );
        }

        if ( !isset( $options['startId'] ) || !isset( $options['endId'] ) ) {
            throw new InvalidInputException( 'Both startId and endId must be provided' );
        }

        /** @var RevisionViewQuery */
        $query = Container::get( 'query.post.view' );
        $rows = $query->getUndoDiffResult( $options['startId'], $options['endId'] );
        if ( !$rows ) {
            throw new InvalidInputException( 'Could not load revision to undo' );
        }

        $serializer = Container::get( 'formatter.undoedit' );
        return $serializer->formatApi( $rows[0], $rows[1], $rows[2], $this->context );
    }

    /**
     * @param string $format Content format (html|wikitext|fixed-html|topic-title-html|topic-title-wikitext)
     * @return RevisionFormatter
     */
    protected function getRevisionFormatter( $format ) {
        $serializer = Container::get( 'formatter.revision.factory' )->create();
        $serializer->setContentFormat( $format );

        return $serializer;
    }

    protected function renderTopicHistoryApi( array $options, $navbar = true ) {
        if ( $this->workflow->isNew() ) {
            throw new FlowException( 'No topic history can exist for non-existent topic' );
        }
        return $this->processHistoryResult( Container::get( 'query.topic.history' ),
            $this->workflow->getId(), $options, $navbar );
    }

    protected function renderPostHistoryApi( array $options, UUID $postId, $navbar = true ) {
        if ( $this->workflow->isNew() ) {
            throw new FlowException( 'No post history can exist for non-existent topic' );
        }
        return $this->processHistoryResult( Container::get( 'query.post.history' ),
            $postId, $options, $navbar );
    }

    /**
     * Process the history result for either topic or post
     *
     * @param TopicHistoryQuery|PostHistoryQuery $query
     * @param UUID $uuid
     * @param array $options
     * @param bool $navbar Whether to include the page navbar
     * @return array
     */
    protected function processHistoryResult(
        /* TopicHistoryQuery|PostHistoryQuery */ $query,
        UUID $uuid,
        $options,
        $navbar = true
    ) {
        global $wgRequest;

        $format = $options['format'] ?? 'fixed-html';
        $serializer = $this->getRevisionFormatter( $format );
        $serializer->setIncludeHistoryProperties( true );

        [ $limit, /* $offset */ ] = $wgRequest->getLimitOffsetForUser(
            $this->context->getUser()
        );
        // don't use offset from getLimitOffset - that assumes an int, which our
        // UUIDs are not
        $offset = $wgRequest->getText( 'offset' );
        $offset = $offset ? UUID::create( $offset ) : null;

        $pager = new HistoryPager( $query, $uuid );
        $pager->setLimit( $limit );
        $pager->setOffset( $offset );
        $pager->doQuery();
        $history = $pager->getResult();

        $revisions = [];
        foreach ( $history as $row ) {
            // @phan-suppress-next-line PhanTypeMismatchArgument
            $serialized = $serializer->formatApi( $row, $this->context, 'history' );
            // if the user is not allowed to see this row it will return empty
            if ( $serialized ) {
                $revisions[] = $serialized;
            }
        }

        $response = [ 'revisions' => $revisions ];
        if ( $navbar ) {
            $response['navbar'] = $pager->getNavigationBar();
        }
        return $response;
    }

    /**
     * @return PostRevision|null
     */
    public function loadRootPost() {
        if ( $this->root !== null ) {
            return $this->root;
        }

        $rootPost = $this->rootLoader->get( $this->workflow->getId() );

        if ( $this->permissions->isAllowed( $rootPost, 'view' ) ) {
            // topicTitle is same as root, difference is root has children populated to full depth
            $this->topicTitle = $rootPost;
            $this->root = $rootPost;
            return $rootPost;
        }

        $this->addError( 'moderation', $this->context->msg( 'flow-error-not-allowed' ) );

        return null;
    }

    /**
     * @param string $action Permissions action to require to return revision
     * @return AbstractRevision|null
     * @throws InvalidDataException
     */
    public function loadTopicTitle( $action = 'view' ) {
        if ( $this->workflow->isNew() ) {
            throw new InvalidDataException( 'New workflows do not have any related content',
                'missing-topic-title' );
        }

        if ( $this->topicTitle === null ) {
            $found = $this->storage->find(
                'PostRevision',
                [ 'rev_type_id' => $this->workflow->getId() ],
                [ 'sort' => 'rev_id', 'order' => 'DESC', 'limit' => 1 ]
            );
            if ( !$found ) {
                throw new InvalidDataException( 'Every workflow must have an associated topic title',
                    'missing-topic-title' );
            }
            $this->topicTitle = reset( $found );

            // this method loads only title, nothing else; otherwise, you're
            // looking for loadRootPost
            $this->topicTitle->setChildren( [] );
            $this->topicTitle->setDepth( 0 );
            $this->topicTitle->setRootPost( $this->topicTitle );
        }

        if ( !$this->permissions->isAllowed( $this->topicTitle, $action ) ) {
            $this->addError( 'permissions', $this->getDisallowedErrorMessage( $this->topicTitle ) );
            return null;
        }

        return $this->topicTitle;
    }

    /**
     * @todo Move this to AbstractBlock and use for summary/header/etc.
     * @param AbstractRevision $revision
     * @return Message
     */
    protected function getDisallowedErrorMessage( AbstractRevision $revision ) {
        if ( in_array( $this->action, [ 'moderate-topic', 'moderate-post' ] ) ) {
            /*
             * When failing to moderate an already moderated action (like
             * undo), show the more general "you have insufficient
             * permissions for this action" message, rather than the
             * specialized "this topic is <hidden|deleted|suppressed>" msg.
             */
            return $this->context->msg( 'flow-error-not-allowed' );
        }

        $state = $revision->getModerationState();

        // display simple message
        // i18n messages:
        // flow-error-not-allowed-hide,
        // flow-error-not-allowed-reply-to-hide-topic
        // flow-error-not-allowed-delete
        // flow-error-not-allowed-reply-to-delete-topic
        // flow-error-not-allowed-suppress
        // flow-error-not-allowed-reply-to-suppress-topic
        if ( $revision instanceof PostRevision ) {
            $type = $revision->isTopicTitle() ? 'topic' : 'post';
        } else {
            $type = $revision->getRevisionType();
        }

        // Show a snippet of the relevant log entry if available.
        if ( \LogPage::isLogType( $state ) ) {
            // check if user has sufficient permissions to see log
            $logPage = new \LogPage( $state );
            if ( MediaWikiServices::getInstance()->getPermissionManager()
                    ->userHasRight( $this->context->getUser(), $logPage->getRestriction() )
            ) {
                // LogEventsList::showLogExtract will write to OutputPage, but we
                // actually just want that text, to write it ourselves wherever we want,
                // so let's create an OutputPage object to then get the content from.
                $rc = new \RequestContext();
                $output = $rc->getOutput();

                // get log extract
                $entries = \LogEventsList::showLogExtract(
                    $output,
                    [ $state ],
                    $this->workflow->getArticleTitle()->getPrefixedText(),
                    '',
                    [
                        'lim' => 10,
                        'showIfEmpty' => false,
                        // i18n messages:
                        // flow-error-not-allowed-hide-extract
                        // flow-error-not-allowed-reply-to-hide-topic-extract
                        // flow-error-not-allowed-delete-extract
                        // flow-error-not-allowed-reply-to-delete-topic-extract
                        // flow-error-not-allowed-suppress-extract
                        // flow-error-not-allowed-reply-to-suppress-topic-extract
                        'msgKey' => [
                            [
                                "flow-error-not-allowed-{$this->action}-to-$state-$type",
                                "flow-error-not-allowed-$state-extract",
                            ],
                        ]
                    ]
                );

                // check if there were any log extracts
                if ( $entries ) {
                    $message = new RawMessage( '$1' );
                    return $message->rawParams( $output->getHTML() );
                }
            }
        }

        return $this->context->msg( [
            // set of keys to try in order
            "flow-error-not-allowed-{$this->action}-to-$state-$type",
            "flow-error-not-allowed-$state",
            "flow-error-not-allowed"
        ] );
    }

    /**
     * Loads the post referenced by $postId. Returns null when:
     *    $postId does not belong to the workflow
     *    The user does not have view access to the topic title
     *    The user does not have view access to the referenced post
     * All these conditions add a relevant error message to $this->errors when returning null
     *
     * @param UUID|string $postId The post being requested
     * @return PostRevision|null
     */
    protected function loadRequestedPost( $postId ) {
        if ( !$postId instanceof UUID ) {
            $postId = UUID::create( $postId );
        }
        '@phan-var UUID $postId';

        if ( $this->rootLoader === null ) {
            // Since there is no root loader the full tree is already loaded
            $topicTitle = $root = $this->loadRootPost();
            if ( !$topicTitle ) {
                return null;
            }
            $post = $root->getDescendant( $postId );
            if ( $post === null ) {
                // The requested postId is not a member of the current workflow
                $this->addError( 'post', $this->context->msg(
                    'flow-error-invalid-postId', $postId->getAlphadecimal() ) );
                return null;
            }
        } else {
            // Load the post and its root
            $found = $this->rootLoader->getWithRoot( $postId );
            if ( !$found['post'] || !$found['root'] ||
                !$found['root']->getPostId()->equals( $this->workflow->getId() )
            ) {
                $this->addError( 'post', $this->context->msg(
                    'flow-error-invalid-postId', $postId->getAlphadecimal() ) );
                return null;
            }
            $this->topicTitle = $topicTitle = $found['root'];
            $post = $found['post'];

            // using the path to the root post, we can know the post's depth
            $rootPath = $this->rootLoader->getTreeRepo()->findRootPath( $postId );
            $post->setDepth( count( $rootPath ) - 1 );
            $post->setRootPost( $found['root'] );
        }

        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
        if ( $this->permissions->isAllowed( $topicTitle, 'view' )
            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
            && $this->permissions->isAllowed( $post, 'view' ) ) {
            return $post;
        }

        $this->addError( 'moderation', $this->context->msg( 'flow-error-not-allowed' ) );
        return null;
    }

    /**
     * The prefix used for form data$pos
     * @return string
     */
    public function getName() {
        return 'topic';
    }

    /**
     * @param OutputPage $out
     *
     * @todo Provide more informative page title for actions other than view,
     *       e.g. "Hide post in <TITLE>", "Unlock <TITLE>", etc.
     */
    public function setPageTitle( OutputPage $out ) {
        $topic = $this->loadTopicTitle( $this->action === 'history' ? 'history' : 'view' );
        if ( !$topic ) {
            return;
        }

        $title = $this->workflow->getOwnerTitle();
        $convertedTitle = Utils::getConvertedTitle( $title );
        $out->setPageTitleMsg( $out->msg( 'flow-topic-first-heading', $convertedTitle ) );
        if ( $this->permissions->isAllowed( $topic, 'view' ) ) {
            if ( $this->action === 'undo-edit-post' ) {
                $key = 'flow-undo-edit-post';
            } else {
                $key = 'flow-topic-html-title';
            }
            $out->setHTMLTitle( $out->msg( $key,
                // This must be a rawParam to not expand {{foo}} in the title, it must
                // not be htmlspecialchar'd because OutputPage::setHtmlTitle handles that.
                Message::rawParam( $topic->getContent( 'topic-title-plaintext' ) ),
                $convertedTitle
            ) );
        } else {
            $out->setHTMLTitle( $convertedTitle );
        }
        $out->setSubtitle( '&lt; ' .
            MediaWikiServices::getInstance()->getLinkRenderer()->makeLink( $title ) );
    }
}