Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
60.14% |
83 / 138 |
|
57.14% |
4 / 7 |
CRAP | |
0.00% |
0 / 1 |
TranslateSandbox | |
60.14% |
83 / 138 |
|
57.14% |
4 / 7 |
52.64 | |
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% |
35 / 35 |
|
100.00% |
1 / 1 |
3 | |||
getUsers | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
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\Deferred\SiteStatsUpdate; |
14 | use MediaWiki\Extension\Translate\HookRunner; |
15 | use MediaWiki\Extension\Translate\SystemUsers\TranslateUserManager; |
16 | use MediaWiki\Extension\Translate\Utilities\Utilities; |
17 | use MediaWiki\MediaWikiServices; |
18 | use MediaWiki\Permissions\PermissionManager; |
19 | use MediaWiki\SpecialPage\SpecialPage; |
20 | use MediaWiki\User\ActorStore; |
21 | use MediaWiki\User\Options\UserOptionsManager; |
22 | use MediaWiki\User\User; |
23 | use MediaWiki\User\UserArray; |
24 | use MediaWiki\User\UserFactory; |
25 | use MediaWiki\User\UserGroupManager; |
26 | use RuntimeException; |
27 | use UnexpectedValueException; |
28 | use Wikimedia\Rdbms\IConnectionProvider; |
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 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 | } |