Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
53.33% covered (warning)
53.33%
64 / 120
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
TranslateSandbox
53.33% covered (warning)
53.33%
64 / 120
42.86% covered (danger)
42.86%
3 / 7
76.76
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 addUser
62.50% covered (warning)
62.50%
20 / 32
0.00% covered (danger)
0.00%
0 / 1
7.90
 deleteUser
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 getUsers
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 promoteUser
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 sendEmail
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
56
 isSandboxed
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\TranslatorSandbox;
5
6use InvalidArgumentException;
7use JobQueueGroup;
8use MailAddress;
9use MediaWiki\Auth\AuthenticationRequest;
10use MediaWiki\Auth\AuthenticationResponse;
11use MediaWiki\Auth\AuthManager;
12use MediaWiki\Config\ServiceOptions;
13use MediaWiki\Extension\Translate\HookRunner;
14use MediaWiki\Extension\Translate\SystemUsers\TranslateUserManager;
15use MediaWiki\Extension\Translate\Utilities\Utilities;
16use MediaWiki\MediaWikiServices;
17use MediaWiki\Permissions\PermissionManager;
18use MediaWiki\SpecialPage\SpecialPage;
19use MediaWiki\User\ActorStore;
20use MediaWiki\User\User;
21use MediaWiki\User\UserArray;
22use MediaWiki\User\UserFactory;
23use MediaWiki\User\UserGroupManager;
24use MediaWiki\User\UserOptionsManager;
25use RuntimeException;
26use SiteStatsUpdate;
27use UnexpectedValueException;
28use Wikimedia\Rdbms\ILoadBalancer;
29use Wikimedia\ScopedCallback;
30
31/**
32 * Utility class for the sandbox feature of Translate. Do not try this yourself. This code makes a
33 * lot of assumptions about what happens to the user account.
34 *
35 * @author Niklas Laxström
36 * @license GPL-2.0-or-later
37 */
38class TranslateSandbox {
39    public const CONSTRUCTOR_OPTIONS = [
40        'EmergencyContact',
41        'TranslateSandboxPromotedGroup',
42    ];
43
44    private UserFactory $userFactory;
45    private ILoadBalancer $loadBalancer;
46    private PermissionManager $permissionManager;
47    private AuthManager $authManager;
48    private UserGroupManager $userGroupManager;
49    private ActorStore $actorStore;
50    private UserOptionsManager $userOptionsManager;
51    private JobQueueGroup $jobQueueGroup;
52    private HookRunner $hookRunner;
53    private ServiceOptions $options;
54
55    public function __construct(
56        UserFactory $userFactory,
57        ILoadBalancer $loadBalancer,
58        PermissionManager $permissionManager,
59        AuthManager $authManager,
60        UserGroupManager $userGroupManager,
61        ActorStore $actorStore,
62        UserOptionsManager $userOptionsManager,
63        JobQueueGroup $jobQueueGroup,
64        HookRunner $hookRunner,
65        ServiceOptions $options
66    ) {
67        $this->userFactory = $userFactory;
68        $this->loadBalancer = $loadBalancer;
69        $this->permissionManager = $permissionManager;
70        $this->authManager = $authManager;
71        $this->userGroupManager = $userGroupManager;
72        $this->actorStore = $actorStore;
73        $this->userOptionsManager = $userOptionsManager;
74        $this->jobQueueGroup = $jobQueueGroup;
75        $this->hookRunner = $hookRunner;
76        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
77        $this->options = $options;
78    }
79
80    /**
81     * Custom exception code used when user creation fails in order to differentiate between
82     * other exceptions that might occur.
83     */
84    public const USER_CREATION_FAILURE = 56739;
85
86    /** Adds a new user without doing much validation. */
87    public function addUser( string $name, string $email, string $password ): User {
88        $user = $this->userFactory->newFromName( $name, UserFactory::RIGOR_CREATABLE );
89
90        if ( !$user ) {
91            throw new InvalidArgumentException( 'Invalid user name' );
92        }
93
94        $data = [
95            'username' => $user->getName(),
96            'password' => $password,
97            'retype' => $password,
98            'email' => $email,
99            'realname' => '',
100        ];
101
102        $creator = TranslateUserManager::getUser();
103        $guard = $this->permissionManager->addTemporaryUserRights( $creator, 'createaccount' );
104
105        $reqs = $this->authManager->getAuthenticationRequests( AuthManager::ACTION_CREATE );
106        $reqs = AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data );
107        $res = $this->authManager->beginAccountCreation( $creator, $reqs, 'null:' );
108
109        ScopedCallback::consume( $guard );
110
111        switch ( $res->status ) {
112            case AuthenticationResponse::PASS:
113                break;
114            case AuthenticationResponse::FAIL:
115                // Unless things are misconfigured, this will handle errors such as username taken,
116                // invalid user name or too short password. The WebAPI is prechecking these to
117                // provide nicer error messages.
118                $reason = $res->message->inLanguage( 'en' )->useDatabase( false )->text();
119                throw new RuntimeException(
120                    "Account creation failed: $reason",
121                    self::USER_CREATION_FAILURE
122                );
123            default:
124                // A provider requested further user input. Abort but clean up first if it was a
125                // secondary provider (in which case the user was created).
126                if ( $user->getId() ) {
127                    $this->deleteUser( $user, 'force' );
128                }
129
130                throw new RuntimeException(
131                    'AuthManager does not support such simplified account creation'
132                );
133        }
134
135        // group-translate-sandboxed group-translate-sandboxed-member
136        $this->userGroupManager->addUserToGroup( $user, 'translate-sandboxed' );
137
138        return $user;
139    }
140
141    /**
142     * Deletes a sandboxed user without doing much validation.
143     *
144     * @param User $user
145     * @param string $force If set to 'force' will skip the little validation we have.
146     * @throws UserNotSandboxedException
147     */
148    public function deleteUser( User $user, string $force = '' ): void {
149        $uid = $user->getId();
150        $actorId = $user->getActorId();
151
152        if ( $force !== 'force' && !self::isSandboxed( $user ) ) {
153            throw new UserNotSandboxedException();
154        }
155
156        // Delete from database
157        $dbw = $this->loadBalancer->getConnection( DB_PRIMARY );
158        $dbw->delete( 'user', [ 'user_id' => $uid ], __METHOD__ );
159        $dbw->delete( 'user_groups', [ 'ug_user' => $uid ], __METHOD__ );
160        $dbw->delete( 'user_properties', [ 'up_user' => $uid ], __METHOD__ );
161
162        $this->actorStore->deleteActor( $user, $dbw );
163
164        // Assume no joins are needed for logging or recentchanges
165        $dbw->delete( 'logging', [ 'log_actor' => $actorId ], __METHOD__ );
166        $dbw->delete( 'recentchanges', [ 'rc_actor' => $actorId ], __METHOD__ );
167
168        // Update the site stats
169        $statsUpdate = SiteStatsUpdate::factory( [ 'users' => -1 ] );
170        $statsUpdate->doUpdate();
171
172        // If someone tries to access still object still, they will get anon user
173        // data.
174        $user->clearInstanceCache( 'defaults' );
175
176        // Nobody should access the user by id anymore, but in case they do, purge
177        // the cache so they wont get stale data
178        $user->invalidateCache();
179    }
180
181    /** Get all sandboxed users. */
182    public function getUsers(): UserArray {
183        $dbr = Utilities::getSafeReadDB();
184        // MW < 1.41
185        if ( method_exists( User::class, 'newQueryBuilder' ) ) {
186            $query = User::newQueryBuilder( $dbr );
187        } else {
188            $query = $dbr->newSelectQueryBuilder()->queryInfo( User::getQueryInfo() );
189        }
190
191        $res = $query->join( 'user_groups', null, 'ug_user = user_id' )
192            ->where( [ 'ug_group' => 'translate-sandboxed' ] )
193            ->caller( __METHOD__ )
194            ->fetchResultSet();
195
196        return UserArray::newFromResult( $res );
197    }
198
199    /**
200     * Removes the user from the sandbox.
201     * @throws UserNotSandboxedException
202     */
203    public function promoteUser( User $user ): void {
204        $translateSandboxPromotedGroup = $this->options->get( 'TranslateSandboxPromotedGroup' );
205
206        if ( !self::isSandboxed( $user ) ) {
207            throw new UserNotSandboxedException();
208        }
209
210        $this->userGroupManager->removeUserFromGroup( $user, 'translate-sandboxed' );
211        if ( $translateSandboxPromotedGroup ) {
212            $this->userGroupManager->addUserToGroup( $user, $translateSandboxPromotedGroup );
213        }
214
215        $this->userOptionsManager->setOption( $user, 'translate-sandbox-reminders', null );
216        $this->userOptionsManager->saveOptions( $user );
217
218        $this->hookRunner->onTranslate_TranslatorSandbox_UserPromoted( $user );
219    }
220
221    /**
222     * Sends a reminder to the user.
223     * @param User $sender
224     * @param User $target
225     * @param string $type 'reminder' or 'promotion'
226     * @throws UserNotSandboxedException
227     */
228    public function sendEmail( User $sender, User $target, string $type ): void {
229        $emergencyContact = $this->options->get( 'EmergencyContact' );
230
231        $targetLang = $this->userOptionsManager->getOption( $target, 'language' );
232
233        switch ( $type ) {
234            case 'reminder':
235                if ( !self::isSandboxed( $target ) ) {
236                    throw new UserNotSandboxedException();
237                }
238
239                $subjectMsg = 'tsb-reminder-title-generic';
240                $bodyMsg = 'tsb-reminder-content-generic';
241                $targetSpecialPage = 'TranslationStash';
242
243                break;
244            case 'promotion':
245                $subjectMsg = 'tsb-email-promoted-subject';
246                $bodyMsg = 'tsb-email-promoted-body';
247                $targetSpecialPage = 'Translate';
248
249                break;
250            case 'rejection':
251                $subjectMsg = 'tsb-email-rejected-subject';
252                $bodyMsg = 'tsb-email-rejected-body';
253                $targetSpecialPage = 'TwnMainPage';
254
255                break;
256            default:
257                throw new UnexpectedValueException( "'$type' is an invalid type of translate sandbox email" );
258        }
259
260        $subject = wfMessage( $subjectMsg )->inLanguage( $targetLang )->text();
261        $body = wfMessage(
262            $bodyMsg,
263            $target->getName(),
264            SpecialPage::getTitleFor( $targetSpecialPage )->getCanonicalURL(),
265            $sender->getName()
266        )->inLanguage( $targetLang )->text();
267
268        $params = [
269            'user' => $target->getId(),
270            'to' => MailAddress::newFromUser( $target ),
271            'from' => new MailAddress( $emergencyContact ),
272            'replyto' => new MailAddress( $emergencyContact ),
273            'subj' => $subject,
274            'body' => $body,
275            'emailType' => $type,
276        ];
277
278        $reminders = $this->userOptionsManager->getOption( $target, 'translate-sandbox-reminders' );
279        $reminders = $reminders ? explode( '|', $reminders ) : [];
280        $reminders[] = wfTimestamp();
281
282        $this->userOptionsManager->setOption( $target, 'translate-sandbox-reminders', implode( '|', $reminders ) );
283        $this->userOptionsManager->saveOptions( $target );
284
285        $this->jobQueueGroup->push( TranslateSandboxEmailJob::newJob( $params ) );
286    }
287
288    /** Shortcut for checking if given user is in the sandbox. */
289    public static function isSandboxed( User $user ): bool {
290        $userGroupManager = MediaWikiServices::getInstance()->getUserGroupManager();
291        return in_array( 'translate-sandboxed', $userGroupManager->getUserGroups( $user ), true );
292    }
293}