wikimedia/mediawiki-extensions-Translate

View on GitHub
src/TranslatorSandbox/TranslateSandbox.php

Summary

Maintainability
B
6 hrs
Test Coverage
<?php
declare( strict_types = 1 );

namespace MediaWiki\Extension\Translate\TranslatorSandbox;

use InvalidArgumentException;
use JobQueueGroup;
use MailAddress;
use MediaWiki\Auth\AuthenticationRequest;
use MediaWiki\Auth\AuthenticationResponse;
use MediaWiki\Auth\AuthManager;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Deferred\SiteStatsUpdate;
use MediaWiki\Extension\Translate\HookRunner;
use MediaWiki\Extension\Translate\SystemUsers\TranslateUserManager;
use MediaWiki\Extension\Translate\Utilities\Utilities;
use MediaWiki\MediaWikiServices;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\SpecialPage\SpecialPage;
use MediaWiki\User\ActorStore;
use MediaWiki\User\Options\UserOptionsManager;
use MediaWiki\User\User;
use MediaWiki\User\UserArray;
use MediaWiki\User\UserFactory;
use MediaWiki\User\UserGroupManager;
use RuntimeException;
use UnexpectedValueException;
use Wikimedia\Rdbms\IConnectionProvider;
use Wikimedia\ScopedCallback;

/**
 * Utility class for the sandbox feature of Translate. Do not try this yourself. This code makes a
 * lot of assumptions about what happens to the user account.
 *
 * @author Niklas Laxström
 * @license GPL-2.0-or-later
 */
class TranslateSandbox {
    public const CONSTRUCTOR_OPTIONS = [
        'EmergencyContact',
        'TranslateSandboxPromotedGroup',
    ];

    private UserFactory $userFactory;
    private IConnectionProvider $dbProvider;
    private PermissionManager $permissionManager;
    private AuthManager $authManager;
    private UserGroupManager $userGroupManager;
    private ActorStore $actorStore;
    private UserOptionsManager $userOptionsManager;
    private JobQueueGroup $jobQueueGroup;
    private HookRunner $hookRunner;
    private ServiceOptions $options;

    public function __construct(
        UserFactory $userFactory,
        IConnectionProvider $dbProvider,
        PermissionManager $permissionManager,
        AuthManager $authManager,
        UserGroupManager $userGroupManager,
        ActorStore $actorStore,
        UserOptionsManager $userOptionsManager,
        JobQueueGroup $jobQueueGroup,
        HookRunner $hookRunner,
        ServiceOptions $options
    ) {
        $this->userFactory = $userFactory;
        $this->dbProvider = $dbProvider;
        $this->permissionManager = $permissionManager;
        $this->authManager = $authManager;
        $this->userGroupManager = $userGroupManager;
        $this->actorStore = $actorStore;
        $this->userOptionsManager = $userOptionsManager;
        $this->jobQueueGroup = $jobQueueGroup;
        $this->hookRunner = $hookRunner;
        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
        $this->options = $options;
    }

    /**
     * Custom exception code used when user creation fails in order to differentiate between
     * other exceptions that might occur.
     */
    public const USER_CREATION_FAILURE = 56739;

    /** Adds a new user without doing much validation. */
    public function addUser( string $name, string $email, string $password ): User {
        $user = $this->userFactory->newFromName( $name, UserFactory::RIGOR_CREATABLE );

        if ( !$user ) {
            throw new InvalidArgumentException( 'Invalid user name' );
        }

        $data = [
            'username' => $user->getName(),
            'password' => $password,
            'retype' => $password,
            'email' => $email,
            'realname' => '',
        ];

        $creator = TranslateUserManager::getUser();
        $guard = $this->permissionManager->addTemporaryUserRights( $creator, 'createaccount' );

        $reqs = $this->authManager->getAuthenticationRequests( AuthManager::ACTION_CREATE );
        $reqs = AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data );
        $res = $this->authManager->beginAccountCreation( $creator, $reqs, 'null:' );

        ScopedCallback::consume( $guard );

        switch ( $res->status ) {
            case AuthenticationResponse::PASS:
                break;
            case AuthenticationResponse::FAIL:
                // Unless things are misconfigured, this will handle errors such as username taken,
                // invalid user name or too short password. The WebAPI is prechecking these to
                // provide nicer error messages.
                $reason = $res->message->inLanguage( 'en' )->useDatabase( false )->text();
                throw new RuntimeException(
                    "Account creation failed: $reason",
                    self::USER_CREATION_FAILURE
                );
            default:
                // A provider requested further user input. Abort but clean up first if it was a
                // secondary provider (in which case the user was created).
                if ( $user->getId() ) {
                    $this->deleteUser( $user, 'force' );
                }

                throw new RuntimeException(
                    'AuthManager does not support such simplified account creation'
                );
        }

        // group-translate-sandboxed group-translate-sandboxed-member
        $this->userGroupManager->addUserToGroup( $user, 'translate-sandboxed' );

        return $user;
    }

    /**
     * Deletes a sandboxed user without doing much validation.
     *
     * @param User $user
     * @param string $force If set to 'force' will skip the little validation we have.
     * @throws UserNotSandboxedException
     */
    public function deleteUser( User $user, string $force = '' ): void {
        $uid = $user->getId();
        $actorId = $user->getActorId();

        if ( $force !== 'force' && !self::isSandboxed( $user ) ) {
            throw new UserNotSandboxedException();
        }

        // Delete from database
        $dbw = $this->dbProvider->getPrimaryDatabase();
        $dbw->newDeleteQueryBuilder()
            ->deleteFrom( 'user' )
            ->where( [ 'user_id' => $uid ] )
            ->caller( __METHOD__ )
            ->execute();
        $dbw->newDeleteQueryBuilder()
            ->deleteFrom( 'user_groups' )
            ->where( [ 'ug_user' => $uid ] )
            ->caller( __METHOD__ )
            ->execute();
        $dbw->newDeleteQueryBuilder()
            ->deleteFrom( 'user_properties' )
            ->where( [ 'up_user' => $uid ] )
            ->caller( __METHOD__ )
            ->execute();

        $this->actorStore->deleteActor( $user, $dbw );

        // Assume no joins are needed for logging or recentchanges
        $dbw->newDeleteQueryBuilder()
            ->deleteFrom( 'logging' )
            ->where( [ 'log_actor' => $actorId ] )
            ->caller( __METHOD__ )
            ->execute();
        $dbw->newDeleteQueryBuilder()
            ->deleteFrom( 'recentchanges' )
            ->where( [ 'rc_actor' => $actorId ] )
            ->caller( __METHOD__ )
            ->execute();

        // Update the site stats
        $statsUpdate = SiteStatsUpdate::factory( [ 'users' => -1 ] );
        $statsUpdate->doUpdate();

        // If someone tries to access still object still, they will get anon user
        // data.
        $user->clearInstanceCache( 'defaults' );

        // Nobody should access the user by id anymore, but in case they do, purge
        // the cache so they wont get stale data
        $user->invalidateCache();
    }

    /** Get all sandboxed users. */
    public function getUsers(): UserArray {
        $dbr = Utilities::getSafeReadDB();
        $query = User::newQueryBuilder( $dbr );

        $res = $query->join( 'user_groups', null, 'ug_user = user_id' )
            ->where( [ 'ug_group' => 'translate-sandboxed' ] )
            ->caller( __METHOD__ )
            ->fetchResultSet();

        return UserArray::newFromResult( $res );
    }

    /**
     * Removes the user from the sandbox.
     * @throws UserNotSandboxedException
     */
    public function promoteUser( User $user ): void {
        $translateSandboxPromotedGroup = $this->options->get( 'TranslateSandboxPromotedGroup' );

        if ( !self::isSandboxed( $user ) ) {
            throw new UserNotSandboxedException();
        }

        $this->userGroupManager->removeUserFromGroup( $user, 'translate-sandboxed' );
        if ( $translateSandboxPromotedGroup ) {
            $this->userGroupManager->addUserToGroup( $user, $translateSandboxPromotedGroup );
        }

        $this->userOptionsManager->setOption( $user, 'translate-sandbox-reminders', null );
        $this->userOptionsManager->saveOptions( $user );

        $this->hookRunner->onTranslate_TranslatorSandbox_UserPromoted( $user );
    }

    /**
     * Sends a reminder to the user.
     * @param User $sender
     * @param User $target
     * @param string $type 'reminder' or 'promotion'
     * @throws UserNotSandboxedException
     */
    public function sendEmail( User $sender, User $target, string $type ): void {
        $emergencyContact = $this->options->get( 'EmergencyContact' );

        $targetLang = $this->userOptionsManager->getOption( $target, 'language' );

        switch ( $type ) {
            case 'reminder':
                if ( !self::isSandboxed( $target ) ) {
                    throw new UserNotSandboxedException();
                }

                $subjectMsg = 'tsb-reminder-title-generic';
                $bodyMsg = 'tsb-reminder-content-generic';
                $targetSpecialPage = 'TranslationStash';

                break;
            case 'promotion':
                $subjectMsg = 'tsb-email-promoted-subject';
                $bodyMsg = 'tsb-email-promoted-body';
                $targetSpecialPage = 'Translate';

                break;
            case 'rejection':
                $subjectMsg = 'tsb-email-rejected-subject';
                $bodyMsg = 'tsb-email-rejected-body';
                $targetSpecialPage = 'TwnMainPage';

                break;
            default:
                throw new UnexpectedValueException( "'$type' is an invalid type of translate sandbox email" );
        }

        $subject = wfMessage( $subjectMsg )->inLanguage( $targetLang )->text();
        $body = wfMessage(
            $bodyMsg,
            $target->getName(),
            SpecialPage::getTitleFor( $targetSpecialPage )->getCanonicalURL(),
            $sender->getName()
        )->inLanguage( $targetLang )->text();

        $params = [
            'user' => $target->getId(),
            'to' => MailAddress::newFromUser( $target ),
            'from' => new MailAddress( $emergencyContact ),
            'replyto' => new MailAddress( $emergencyContact ),
            'subj' => $subject,
            'body' => $body,
            'emailType' => $type,
        ];

        $reminders = $this->userOptionsManager->getOption( $target, 'translate-sandbox-reminders' );
        $reminders = $reminders ? explode( '|', $reminders ) : [];
        $reminders[] = wfTimestamp();

        $this->userOptionsManager->setOption( $target, 'translate-sandbox-reminders', implode( '|', $reminders ) );
        $this->userOptionsManager->saveOptions( $target );

        $this->jobQueueGroup->push( TranslateSandboxEmailJob::newJob( $params ) );
    }

    /** Shortcut for checking if given user is in the sandbox. */
    public static function isSandboxed( User $user ): bool {
        $userGroupManager = MediaWikiServices::getInstance()->getUserGroupManager();
        return in_array( 'translate-sandboxed', $userGroupManager->getUserGroups( $user ), true );
    }
}