Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
60.14% covered (warning)
60.14%
83 / 138
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
TranslateSandbox
60.14% covered (warning)
60.14%
83 / 138
57.14% covered (warning)
57.14%
4 / 7
52.64
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%
35 / 35
100.00% covered (success)
100.00%
1 / 1
3
 getUsers
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 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\Deferred\SiteStatsUpdate;
14use MediaWiki\Extension\Translate\HookRunner;
15use MediaWiki\Extension\Translate\SystemUsers\TranslateUserManager;
16use MediaWiki\Extension\Translate\Utilities\Utilities;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Permissions\PermissionManager;
19use MediaWiki\SpecialPage\SpecialPage;
20use MediaWiki\User\ActorStore;
21use MediaWiki\User\Options\UserOptionsManager;
22use MediaWiki\User\User;
23use MediaWiki\User\UserArray;
24use MediaWiki\User\UserFactory;
25use MediaWiki\User\UserGroupManager;
26use RuntimeException;
27use UnexpectedValueException;
28use Wikimedia\Rdbms\IConnectionProvider;
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 IConnectionProvider $dbProvider;
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        IConnectionProvider $dbProvider,
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->dbProvider = $dbProvider;
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->dbProvider->getPrimaryDatabase();
158        $dbw->newDeleteQueryBuilder()
159            ->deleteFrom( 'user' )
160            ->where( [ 'user_id' => $uid ] )
161            ->caller( __METHOD__ )
162            ->execute();
163        $dbw->newDeleteQueryBuilder()
164            ->deleteFrom( 'user_groups' )
165            ->where( [ 'ug_user' => $uid ] )
166            ->caller( __METHOD__ )
167            ->execute();
168        $dbw->newDeleteQueryBuilder()
169            ->deleteFrom( 'user_properties' )
170            ->where( [ 'up_user' => $uid ] )
171            ->caller( __METHOD__ )
172            ->execute();
173
174        $this->actorStore->deleteActor( $user, $dbw );
175
176        // Assume no joins are needed for logging or recentchanges
177        $dbw->newDeleteQueryBuilder()
178            ->deleteFrom( 'logging' )
179            ->where( [ 'log_actor' => $actorId ] )
180            ->caller( __METHOD__ )
181            ->execute();
182        $dbw->newDeleteQueryBuilder()
183            ->deleteFrom( 'recentchanges' )
184            ->where( [ 'rc_actor' => $actorId ] )
185            ->caller( __METHOD__ )
186            ->execute();
187
188        // Update the site stats
189        $statsUpdate = SiteStatsUpdate::factory( [ 'users' => -1 ] );
190        $statsUpdate->doUpdate();
191
192        // If someone tries to access still object still, they will get anon user
193        // data.
194        $user->clearInstanceCache( 'defaults' );
195
196        // Nobody should access the user by id anymore, but in case they do, purge
197        // the cache so they wont get stale data
198        $user->invalidateCache();
199    }
200
201    /** Get all sandboxed users. */
202    public function getUsers(): UserArray {
203        $dbr = Utilities::getSafeReadDB();
204        $query = User::newQueryBuilder( $dbr );
205
206        $res = $query->join( 'user_groups', null, 'ug_user = user_id' )
207            ->where( [ 'ug_group' => 'translate-sandboxed' ] )
208            ->caller( __METHOD__ )
209            ->fetchResultSet();
210
211        return UserArray::newFromResult( $res );
212    }
213
214    /**
215     * Removes the user from the sandbox.
216     * @throws UserNotSandboxedException
217     */
218    public function promoteUser( User $user ): void {
219        $translateSandboxPromotedGroup = $this->options->get( 'TranslateSandboxPromotedGroup' );
220
221        if ( !self::isSandboxed( $user ) ) {
222            throw new UserNotSandboxedException();
223        }
224
225        $this->userGroupManager->removeUserFromGroup( $user, 'translate-sandboxed' );
226        if ( $translateSandboxPromotedGroup ) {
227            $this->userGroupManager->addUserToGroup( $user, $translateSandboxPromotedGroup );
228        }
229
230        $this->userOptionsManager->setOption( $user, 'translate-sandbox-reminders', null );
231        $this->userOptionsManager->saveOptions( $user );
232
233        $this->hookRunner->onTranslate_TranslatorSandbox_UserPromoted( $user );
234    }
235
236    /**
237     * Sends a reminder to the user.
238     * @param User $sender
239     * @param User $target
240     * @param string $type 'reminder' or 'promotion'
241     * @throws UserNotSandboxedException
242     */
243    public function sendEmail( User $sender, User $target, string $type ): void {
244        $emergencyContact = $this->options->get( 'EmergencyContact' );
245
246        $targetLang = $this->userOptionsManager->getOption( $target, 'language' );
247
248        switch ( $type ) {
249            case 'reminder':
250                if ( !self::isSandboxed( $target ) ) {
251                    throw new UserNotSandboxedException();
252                }
253
254                $subjectMsg = 'tsb-reminder-title-generic';
255                $bodyMsg = 'tsb-reminder-content-generic';
256                $targetSpecialPage = 'TranslationStash';
257
258                break;
259            case 'promotion':
260                $subjectMsg = 'tsb-email-promoted-subject';
261                $bodyMsg = 'tsb-email-promoted-body';
262                $targetSpecialPage = 'Translate';
263
264                break;
265            case 'rejection':
266                $subjectMsg = 'tsb-email-rejected-subject';
267                $bodyMsg = 'tsb-email-rejected-body';
268                $targetSpecialPage = 'TwnMainPage';
269
270                break;
271            default:
272                throw new UnexpectedValueException( "'$type' is an invalid type of translate sandbox email" );
273        }
274
275        $subject = wfMessage( $subjectMsg )->inLanguage( $targetLang )->text();
276        $body = wfMessage(
277            $bodyMsg,
278            $target->getName(),
279            SpecialPage::getTitleFor( $targetSpecialPage )->getCanonicalURL(),
280            $sender->getName()
281        )->inLanguage( $targetLang )->text();
282
283        $params = [
284            'user' => $target->getId(),
285            'to' => MailAddress::newFromUser( $target ),
286            'from' => new MailAddress( $emergencyContact ),
287            'replyto' => new MailAddress( $emergencyContact ),
288            'subj' => $subject,
289            'body' => $body,
290            'emailType' => $type,
291        ];
292
293        $reminders = $this->userOptionsManager->getOption( $target, 'translate-sandbox-reminders' );
294        $reminders = $reminders ? explode( '|', $reminders ) : [];
295        $reminders[] = wfTimestamp();
296
297        $this->userOptionsManager->setOption( $target, 'translate-sandbox-reminders', implode( '|', $reminders ) );
298        $this->userOptionsManager->saveOptions( $target );
299
300        $this->jobQueueGroup->push( TranslateSandboxEmailJob::newJob( $params ) );
301    }
302
303    /** Shortcut for checking if given user is in the sandbox. */
304    public static function isSandboxed( User $user ): bool {
305        $userGroupManager = MediaWikiServices::getInstance()->getUserGroupManager();
306        return in_array( 'translate-sandboxed', $userGroupManager->getUserGroups( $user ), true );
307    }
308}