wikimedia/mediawiki-core

View on GitHub
includes/content/ContentModelChange.php

Summary

Maintainability
C
1 day
Test Coverage
<?php

use MediaWiki\Content\IContentHandlerFactory;
use MediaWiki\Context\DerivativeContext;
use MediaWiki\Context\IContextSource;
use MediaWiki\Context\RequestContext;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\HookContainer\HookRunner;
use MediaWiki\Page\PageIdentity;
use MediaWiki\Permissions\Authority;
use MediaWiki\Permissions\PermissionStatus;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Status\Status;
use MediaWiki\User\UserFactory;

/**
 * Backend logic for changing the content model of a page.
 *
 * Note that you can create a new page directly with a desired content
 * model and format, e.g. via EditPage or externally from ApiEditPage.
 *
 * @since 1.35
 * @author DannyS712
 */
class ContentModelChange {
    /** @var IContentHandlerFactory */
    private $contentHandlerFactory;
    /** @var HookRunner */
    private $hookRunner;
    /** @var RevisionLookup */
    private $revLookup;
    /** @var UserFactory */
    private $userFactory;
    /** @var Authority making the change */
    private $performer;
    /** @var WikiPage */
    private $page;
    /** @var string */
    private $newModel;
    /** @var string[] tags to add */
    private $tags;
    /** @var Content */
    private $newContent;
    /** @var int|false latest revision id, or false if creating */
    private $latestRevId;
    /** @var string 'new' or 'change' */
    private $logAction;
    /** @var string 'apierror-' or empty string, for status messages */
    private $msgPrefix;

    /**
     * @internal Create via the ContentModelChangeFactory service.
     * @param IContentHandlerFactory $contentHandlerFactory
     * @param HookContainer $hookContainer
     * @param RevisionLookup $revLookup
     * @param UserFactory $userFactory
     * @param Authority $performer
     * @param WikiPage $page
     * @param string $newModel
     */
    public function __construct(
        IContentHandlerFactory $contentHandlerFactory,
        HookContainer $hookContainer,
        RevisionLookup $revLookup,
        UserFactory $userFactory,
        Authority $performer,
        WikiPage $page,
        string $newModel
    ) {
        $this->contentHandlerFactory = $contentHandlerFactory;
        $this->hookRunner = new HookRunner( $hookContainer );
        $this->revLookup = $revLookup;
        $this->userFactory = $userFactory;

        $this->performer = $performer;
        $this->page = $page;
        $this->newModel = $newModel;

        // SpecialChangeContentModel doesn't support tags
        // api can specify tags via ::setTags, which also checks if user can add
        // the tags specified
        $this->tags = [];

        // Requires createNewContent to be called first
        $this->logAction = '';

        // Defaults to nothing, for special page
        $this->msgPrefix = '';
    }

    /**
     * @param string $msgPrefix
     */
    public function setMessagePrefix( $msgPrefix ) {
        $this->msgPrefix = $msgPrefix;
    }

    /**
     * @param callable $authorizer ( string $action, PageIdentity $target, PermissionStatus $status )
     * @return PermissionStatus
     */
    private function authorizeInternal( callable $authorizer ): PermissionStatus {
        $current = $this->page->getTitle();
        $titleWithNewContentModel = clone $current;
        $titleWithNewContentModel->setContentModel( $this->newModel );

        $status = PermissionStatus::newEmpty();
        $authorizer( 'editcontentmodel', $current, $status );
        $authorizer( 'edit', $current, $status );
        $authorizer( 'editcontentmodel', $titleWithNewContentModel, $status );
        $authorizer( 'edit', $titleWithNewContentModel, $status );
        return $status;
    }

    /**
     * Check whether $performer can execute the content model change.
     *
     * @note this method does not guarantee full permissions check, so it should
     * only be used to to decide whether to show a content model change form.
     * To authorize the content model change action use {@link self::authorizeChange} instead.
     *
     * @return PermissionStatus
     */
    public function probablyCanChange(): PermissionStatus {
        return $this->authorizeInternal(
            function ( string $action, PageIdentity $target, PermissionStatus $status ) {
                return $this->performer->probablyCan( $action, $target, $status );
            }
        );
    }

    /**
     * Authorize the content model change by $performer.
     *
     * @note this method should be used right before the actual content model change is performed.
     * To check whether a current performer has the potential to change the content model of the page,
     * use {@link self::probablyCanChange} instead.
     *
     * @return PermissionStatus
     */
    public function authorizeChange(): PermissionStatus {
        return $this->authorizeInternal(
            function ( string $action, PageIdentity $target, PermissionStatus $status ) {
                return $this->performer->authorizeWrite( $action, $target, $status );
            }
        );
    }

    /**
     * Check user can edit and editcontentmodel before and after
     *
     * @deprecated since 1.36. Use ::probablyCanChange or ::authorizeChange instead.
     * @return array from wfMergeErrorArrays
     */
    public function checkPermissions() {
        wfDeprecated( __METHOD__, '1.36' );
        $status = $this->authorizeInternal(
            function ( string $action, PageIdentity $target, PermissionStatus $status ) {
                return $this->performer->definitelyCan( $action, $target, $status );
            } );
        return $status->toLegacyErrorArray();
    }

    /**
     * Specify the tags the user wants to add, and check permissions
     *
     * @param string[] $tags
     * @return Status
     */
    public function setTags( $tags ) {
        $tagStatus = ChangeTags::canAddTagsAccompanyingChange( $tags, $this->performer );
        if ( $tagStatus->isOK() ) {
            $this->tags = $tags;
            return Status::newGood();
        } else {
            return $tagStatus;
        }
    }

    /**
     * @return Status
     */
    private function createNewContent() {
        $contentHandlerFactory = $this->contentHandlerFactory;

        $title = $this->page->getTitle();
        $latestRevRecord = $this->revLookup->getRevisionByTitle( $title );

        if ( $latestRevRecord ) {
            $latestContent = $latestRevRecord->getContent( SlotRecord::MAIN );
            $latestHandler = $latestContent->getContentHandler();
            $latestModel = $latestContent->getModel();
            if ( !$latestHandler->supportsDirectEditing() ) {
                // Only reachable via api
                return Status::newFatal(
                    'apierror-changecontentmodel-nodirectediting',
                    ContentHandler::getLocalizedName( $latestModel )
                );
            }

            $newModel = $this->newModel;
            if ( $newModel === $latestModel ) {
                // Only reachable via api
                return Status::newFatal( 'apierror-nochanges' );
            }
            $newHandler = $contentHandlerFactory->getContentHandler( $newModel );
            if ( !$newHandler->canBeUsedOn( $title ) ) {
                // Only reachable via api
                return Status::newFatal(
                    'apierror-changecontentmodel-cannotbeused',
                    ContentHandler::getLocalizedName( $newModel ),
                    Message::plaintextParam( $title->getPrefixedText() )
                );
            }

            try {
                $newContent = $newHandler->unserializeContent(
                    $latestContent->serialize()
                );
            } catch ( MWException $e ) {
                // Messages: changecontentmodel-cannot-convert,
                // apierror-changecontentmodel-cannot-convert
                return Status::newFatal(
                    $this->msgPrefix . 'changecontentmodel-cannot-convert',
                    Message::plaintextParam( $title->getPrefixedText() ),
                    ContentHandler::getLocalizedName( $newModel )
                );
            }
            $this->latestRevId = $latestRevRecord->getId();
            $this->logAction = 'change';
        } else {
            // Page doesn't exist, create an empty content object
            $newContent = $contentHandlerFactory
                ->getContentHandler( $this->newModel )
                ->makeEmptyContent();
            $this->latestRevId = false;
            $this->logAction = 'new';
        }
        $this->newContent = $newContent;
        return Status::newGood();
    }

    /**
     * Handle change and logging after validation
     *
     * Can still be intercepted by hooks
     *
     * @param IContextSource $context
     * @param string $comment
     * @param bool $bot Mark as a bot edit if the user can
     * @return Status
     */
    public function doContentModelChange(
        IContextSource $context,
        string $comment,
        $bot
    ) {
        $status = $this->createNewContent();
        if ( !$status->isGood() ) {
            return $status;
        }

        $page = $this->page;
        $title = $page->getTitle();
        $user = $this->userFactory->newFromAuthority( $this->performer );

        // Create log entry
        $log = new ManualLogEntry( 'contentmodel', $this->logAction );
        $log->setPerformer( $this->performer->getUser() );
        $log->setTarget( $title );
        $log->setComment( $comment );
        $log->setParameters( [
            '4::oldmodel' => $title->getContentModel(),
            '5::newmodel' => $this->newModel
        ] );
        $log->addTags( $this->tags );

        $formatter = LogFormatter::newFromEntry( $log );
        $formatter->setContext( RequestContext::newExtraneousContext( $title ) );
        $reason = $formatter->getPlainActionText();

        if ( $comment !== '' ) {
            $reason .= wfMessage( 'colon-separator' )->inContentLanguage()->text() . $comment;
        }

        // Run edit filters
        $derivativeContext = new DerivativeContext( $context );
        $derivativeContext->setTitle( $title );
        $derivativeContext->setWikiPage( $page );
        $status = new Status();

        $newContent = $this->newContent;

        if ( !$this->hookRunner->onEditFilterMergedContent( $derivativeContext, $newContent,
            $status, $reason, $user, false )
        ) {
            if ( $status->isGood() ) {
                // TODO: extensions should really specify an error message
                $status->fatal( 'hookaborted' );
            }
            return $status;
        }
        if ( !$status->isOK() ) {
            if ( !$status->getMessages() ) {
                $status->fatal( 'hookaborted' );
            }
            return $status;
        }

        // Make the edit
        $flags = $this->latestRevId ? EDIT_UPDATE : EDIT_NEW;
        $flags |= EDIT_INTERNAL;
        if ( $bot && $this->performer->isAllowed( 'bot' ) ) {
            $flags |= EDIT_FORCE_BOT;
        }

        $status = $page->doUserEditContent(
            $newContent,
            $this->performer,
            $reason,
            $flags,
            $this->latestRevId,
            $this->tags
        );

        if ( !$status->isOK() ) {
            return $status;
        }

        $logid = $log->insert();
        $log->publish( $logid );

        $values = [
            'logid' => $logid
        ];

        return Status::newGood( $values );
    }

}