Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
53.33% |
64 / 120 |
|
42.86% |
3 / 7 |
CRAP | |
0.00% |
0 / 1 |
TranslateSandbox | |
53.33% |
64 / 120 |
|
42.86% |
3 / 7 |
76.76 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
addUser | |
62.50% |
20 / 32 |
|
0.00% |
0 / 1 |
7.90 | |||
deleteUser | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
3 | |||
getUsers | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
2.01 | |||
promoteUser | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
3.01 | |||
sendEmail | |
0.00% |
0 / 42 |
|
0.00% |
0 / 1 |
56 | |||
isSandboxed | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\Translate\TranslatorSandbox; |
5 | |
6 | use InvalidArgumentException; |
7 | use JobQueueGroup; |
8 | use MailAddress; |
9 | use MediaWiki\Auth\AuthenticationRequest; |
10 | use MediaWiki\Auth\AuthenticationResponse; |
11 | use MediaWiki\Auth\AuthManager; |
12 | use MediaWiki\Config\ServiceOptions; |
13 | use MediaWiki\Extension\Translate\HookRunner; |
14 | use MediaWiki\Extension\Translate\SystemUsers\TranslateUserManager; |
15 | use MediaWiki\Extension\Translate\Utilities\Utilities; |
16 | use MediaWiki\MediaWikiServices; |
17 | use MediaWiki\Permissions\PermissionManager; |
18 | use MediaWiki\SpecialPage\SpecialPage; |
19 | use MediaWiki\User\ActorStore; |
20 | use MediaWiki\User\User; |
21 | use MediaWiki\User\UserArray; |
22 | use MediaWiki\User\UserFactory; |
23 | use MediaWiki\User\UserGroupManager; |
24 | use MediaWiki\User\UserOptionsManager; |
25 | use RuntimeException; |
26 | use SiteStatsUpdate; |
27 | use UnexpectedValueException; |
28 | use Wikimedia\Rdbms\ILoadBalancer; |
29 | use 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 | */ |
38 | class 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 | } |