Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
17.88% |
69 / 386 |
|
0.00% |
0 / 14 |
CRAP | |
0.00% |
0 / 1 |
VotePage | |
17.88% |
69 / 386 |
|
0.00% |
0 / 14 |
2863.11 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 61 |
|
0.00% |
0 / 1 |
182 | |||
getTitle | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
showForm | |
0.00% |
0 / 55 |
|
0.00% |
0 / 1 |
30 | |||
getBallot | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
doSubmit | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
logVote | |
0.00% |
0 / 63 |
|
0.00% |
0 / 1 |
72 | |||
getSummaryOfVotes | |
79.31% |
69 / 87 |
|
0.00% |
0 / 1 |
22.20 | |||
getVoteDataFromRecord | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
getQuestionMessage | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
getOptionMessages | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
42 | |||
showJumpForm | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
42 | |||
createMostActiveWikiDropdownWidget | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
2 | |||
populateUsersActiveWikiOptions | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
30 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\SecurePoll\Pages; |
4 | |
5 | use MediaWiki\Extension\CentralAuth\User\CentralAuthUser; |
6 | use MediaWiki\Extension\SecurePoll\Ballots\Ballot; |
7 | use MediaWiki\Extension\SecurePoll\Entities\Election; |
8 | use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException; |
9 | use MediaWiki\Extension\SecurePoll\Hooks\HookRunner; |
10 | use MediaWiki\Extension\SecurePoll\SpecialSecurePoll; |
11 | use MediaWiki\Extension\SecurePoll\User\Auth; |
12 | use MediaWiki\Extension\SecurePoll\User\RemoteMWAuth; |
13 | use MediaWiki\Extension\SecurePoll\User\Voter; |
14 | use MediaWiki\Extension\SecurePoll\VoteRecord; |
15 | use MediaWiki\HookContainer\HookContainer; |
16 | use MediaWiki\Html\Html; |
17 | use MediaWiki\HTMLForm\HTMLForm; |
18 | use MediaWiki\Parser\Sanitizer; |
19 | use MediaWiki\Registration\ExtensionRegistry; |
20 | use MediaWiki\Session\SessionManager; |
21 | use MediaWiki\Status\Status; |
22 | use MediaWiki\Title\Title; |
23 | use MediaWiki\User\User; |
24 | use MediaWiki\WikiMap\WikiMap; |
25 | use MobileContext; |
26 | use OOUI\ButtonInputWidget; |
27 | use OOUI\DropdownInputWidget; |
28 | use OOUI\FieldLayout; |
29 | use OOUI\FieldsetLayout; |
30 | use OOUI\FormLayout; |
31 | use OOUI\HiddenInputWidget; |
32 | use OOUI\HtmlSnippet; |
33 | use OOUI\MessageWidget; |
34 | use OOUI\MultilineTextInputWidget; |
35 | use Wikimedia\IPUtils; |
36 | use Wikimedia\Rdbms\ILoadBalancer; |
37 | |
38 | /** |
39 | * The subpage for casting votes. |
40 | */ |
41 | class VotePage extends ActionPage { |
42 | /** @var Election|null */ |
43 | public $election; |
44 | |
45 | /** @var Auth|null */ |
46 | public $auth; |
47 | |
48 | /** @var User|null */ |
49 | public $user; |
50 | |
51 | /** @var Voter|null */ |
52 | public $voter; |
53 | |
54 | private ILoadBalancer $loadBalancer; |
55 | |
56 | private HookRunner $hookRunner; |
57 | |
58 | /** @var string */ |
59 | private $mostActiveWikiFormField; |
60 | |
61 | /** |
62 | * @param SpecialSecurePoll $specialPage |
63 | * @param ILoadBalancer $loadBalancer |
64 | * @param HookContainer $hookContainer |
65 | */ |
66 | public function __construct( |
67 | SpecialSecurePoll $specialPage, |
68 | ILoadBalancer $loadBalancer, |
69 | HookContainer $hookContainer |
70 | ) { |
71 | parent::__construct( $specialPage ); |
72 | $this->loadBalancer = $loadBalancer; |
73 | $this->hookRunner = new HookRunner( $hookContainer ); |
74 | } |
75 | |
76 | /** |
77 | * Execute the subpage. |
78 | * @param array $params Array of subpage parameters. |
79 | */ |
80 | public function execute( $params ) { |
81 | $out = $this->specialPage->getOutput(); |
82 | $out->enableOOUI(); |
83 | $out->addJsConfigVars( 'SecurePollSubPage', 'vote' ); |
84 | $out->addModules( 'ext.securepoll.htmlform' ); |
85 | $out->addModuleStyles( [ |
86 | 'oojs-ui.styles.icons-alerts', |
87 | 'oojs-ui.styles.icons-movement' |
88 | ] ); |
89 | |
90 | if ( !count( $params ) ) { |
91 | $out->addWikiMsg( 'securepoll-too-few-params' ); |
92 | return; |
93 | } |
94 | |
95 | if ( preg_match( '/^[0-9]+$/', $params[0] ) ) { |
96 | $electionId = intval( $params[0] ); |
97 | $this->election = $this->context->getElection( $electionId ); |
98 | } else { |
99 | $electionId = str_replace( '_', ' ', $params[0] ); |
100 | $this->election = $this->context->getElectionByTitle( $electionId ); |
101 | } |
102 | |
103 | if ( !$this->election ) { |
104 | $out->addWikiMsg( 'securepoll-invalid-election', $electionId ); |
105 | return; |
106 | } |
107 | |
108 | $this->auth = $this->election->getAuth(); |
109 | |
110 | // Get voter from session |
111 | $this->voter = $this->auth->getVoterFromSession( $this->election ); |
112 | |
113 | // If there's no session, try creating one. |
114 | // This will fail if the user is not authorized to vote in the election |
115 | if ( !$this->voter ) { |
116 | $status = $this->auth->newAutoSession( $this->election ); |
117 | if ( $status->isOK() ) { |
118 | $this->voter = $status->value; |
119 | } else { |
120 | $out->addWikiTextAsInterface( $status->getWikiText() ); |
121 | |
122 | return; |
123 | } |
124 | } |
125 | |
126 | $this->initLanguage( $this->voter, $this->election ); |
127 | $language = $this->getUserLang(); |
128 | $this->specialPage->getContext()->setLanguage( $language ); |
129 | |
130 | $out->setPageTitle( $this->election->getMessage( 'title' ) ); |
131 | |
132 | if ( !$this->election->isStarted() ) { |
133 | $out->addWikiMsg( |
134 | 'securepoll-not-started', |
135 | $language->timeanddate( $this->election->getStartDate() ), |
136 | $language->date( $this->election->getStartDate() ), |
137 | $language->time( $this->election->getStartDate() ) |
138 | ); |
139 | |
140 | return; |
141 | } |
142 | |
143 | if ( $this->election->isFinished() ) { |
144 | $out->addWikiMsg( |
145 | 'securepoll-finished', |
146 | $language->timeanddate( $this->election->getEndDate() ), |
147 | $language->date( $this->election->getEndDate() ), |
148 | $language->time( $this->election->getEndDate() ) |
149 | ); |
150 | |
151 | return; |
152 | } |
153 | |
154 | // Show jump form if necessary |
155 | if ( $this->election->getProperty( 'jump-url' ) ) { |
156 | $this->showJumpForm(); |
157 | |
158 | return; |
159 | } |
160 | |
161 | // This is when it starts getting all serious; disable JS |
162 | // that might be used to sniff cookies or log voting data. |
163 | $out->disallowUserJs(); |
164 | |
165 | // Show welcome |
166 | if ( $this->voter->isRemote() ) { |
167 | $out->addWikiMsg( 'securepoll-welcome', $this->voter->getName() ); |
168 | } |
169 | |
170 | // Show change notice |
171 | if ( $this->election->hasVoted( $this->voter ) && !$this->election->allowChange() ) { |
172 | $out->addWikiMsg( 'securepoll-change-disallowed' ); |
173 | |
174 | return; |
175 | } |
176 | |
177 | $out->addJsConfigVars( 'SecurePollType', $this->election->getTallyType() ); |
178 | |
179 | $this->mostActiveWikiFormField = "securepoll_e{$electionId}_most_active_wiki"; |
180 | |
181 | // Show/submit the form |
182 | if ( $this->specialPage->getRequest()->wasPosted() ) { |
183 | $this->doSubmit(); |
184 | } else { |
185 | $this->showForm(); |
186 | } |
187 | } |
188 | |
189 | /** |
190 | * @return Title |
191 | */ |
192 | public function getTitle() { |
193 | return $this->specialPage->getPageTitle( 'vote/' . $this->election->getId() ); |
194 | } |
195 | |
196 | /** |
197 | * Show the voting form. |
198 | * @param Status|false $status |
199 | */ |
200 | public function showForm( $status = false ) { |
201 | $out = $this->specialPage->getOutput(); |
202 | |
203 | // Show the introduction |
204 | if ( $this->election->hasVoted( $this->voter ) && $this->election->allowChange() ) { |
205 | $out->addWikiMsg( 'securepoll-change-allowed' ); |
206 | } |
207 | $out->addWikiTextAsInterface( $this->election->getMessage( 'intro' ) ); |
208 | |
209 | // Show form |
210 | $form = new FormLayout( [ |
211 | 'action' => $this->getTitle()->getLocalURL( "action=vote" ), |
212 | 'method' => 'post', |
213 | 'items' => $this->getBallot()->getForm( $status ) |
214 | ] ); |
215 | |
216 | // Show the comments section |
217 | if ( $this->election->getProperty( 'request-comment' ) ) { |
218 | $form->addItems( [ |
219 | new FieldsetLayout( [ |
220 | 'label' => $this->msg( 'securepoll-header-comments' ), |
221 | 'items' => [ |
222 | new FieldLayout( |
223 | new MultilineTextInputWidget( [ |
224 | 'name' => 'securepoll_comment', |
225 | 'rows' => 3, |
226 | // vote_record is a BLOB, so this can't be infinity |
227 | 'maxLength' => 10000, |
228 | ] ), |
229 | [ |
230 | 'label' => new HtmlSnippet( |
231 | $this->election->parseMessage( 'comment-prompt' ) |
232 | ), |
233 | 'align' => 'top' |
234 | ] |
235 | ) |
236 | ] |
237 | ] ) |
238 | ] ); |
239 | } |
240 | |
241 | if ( $this->election->getProperty( 'prompt-active-wiki', true ) ) { |
242 | // Add most active wiki dropdown |
243 | $form->addItems( [ new FieldLayout( |
244 | $this->createMostActiveWikiDropdownWidget(), |
245 | [ |
246 | 'label' => $this->msg( 'securepoll-vote-most-active-wiki-dropdown-label' )->text(), |
247 | 'align' => 'top', |
248 | ] |
249 | ) ] ); |
250 | } |
251 | |
252 | $form->addItems( [ |
253 | new FieldLayout( |
254 | new ButtonInputWidget( [ |
255 | 'id' => 'submit-vote-button', |
256 | 'label' => $this->msg( 'securepoll-submit' )->text(), |
257 | 'flags' => [ 'primary', 'progressive' ], |
258 | 'type' => 'submit', |
259 | 'classes' => [ 'submit-vote-button' ], |
260 | 'infusable' => true |
261 | ] |
262 | ) ), |
263 | new HiddenInputWidget( [ |
264 | 'name' => 'edit_token', |
265 | 'value' => SessionManager::getGlobalSession()->getToken()->toString(), |
266 | ] ) |
267 | ] ); |
268 | |
269 | $out->addHTML( $form ); |
270 | } |
271 | |
272 | /** |
273 | * Get the Ballot for this election, with injected request dependencies. |
274 | * @return Ballot |
275 | */ |
276 | private function getBallot() { |
277 | $ballot = $this->election->getBallot(); |
278 | $ballot->initRequest( |
279 | $this->specialPage->getRequest(), |
280 | $this->specialPage, |
281 | $this->getUserLang() |
282 | ); |
283 | return $ballot; |
284 | } |
285 | |
286 | /** |
287 | * Submit the voting form. If successful, a record is added to the database. |
288 | * Shows an error message on failure. |
289 | */ |
290 | public function doSubmit() { |
291 | $ballot = $this->getBallot(); |
292 | $status = $ballot->submitForm(); |
293 | if ( !$status->isOK() ) { |
294 | $this->showForm( $status ); |
295 | } else { |
296 | $voteRecord = VoteRecord::newFromBallotData( |
297 | $status->value, |
298 | $this->specialPage->getRequest()->getText( 'securepoll_comment' ) |
299 | ); |
300 | $this->logVote( $voteRecord->getBlob() ); |
301 | } |
302 | } |
303 | |
304 | /** |
305 | * Add a vote to the database with the given unencrypted answer record. |
306 | * @param string $record |
307 | */ |
308 | public function logVote( $record ) { |
309 | $out = $this->specialPage->getOutput(); |
310 | $request = $this->specialPage->getRequest(); |
311 | |
312 | $now = wfTimestampNow(); |
313 | |
314 | $crypt = $this->election->getCrypt(); |
315 | if ( !$crypt ) { |
316 | $encrypted = $record; |
317 | } else { |
318 | $status = $crypt->encrypt( $record ); |
319 | if ( !$status->isOK() ) { |
320 | $out->addWikiTextAsInterface( $status->getWikiText( 'securepoll-encrypt-error' ) ); |
321 | |
322 | return; |
323 | } |
324 | $encrypted = $status->value; |
325 | } |
326 | |
327 | $dbw = $this->loadBalancer->getConnection( ILoadBalancer::DB_PRIMARY ); |
328 | $dbw->startAtomic( __METHOD__ ); |
329 | |
330 | // Mark previous votes as old |
331 | $dbw->newUpdateQueryBuilder() |
332 | ->update( 'securepoll_votes' ) |
333 | ->set( [ 'vote_current' => 0 ] ) |
334 | ->where( [ |
335 | 'vote_election' => $this->election->getId(), |
336 | 'vote_voter' => $this->voter->getId(), |
337 | ] ) |
338 | ->caller( __METHOD__ ) |
339 | ->execute(); |
340 | |
341 | $xff = ''; |
342 | if ( !empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { |
343 | $xff = $_SERVER['HTTP_X_FORWARDED_FOR']; |
344 | } |
345 | |
346 | $token = SessionManager::getGlobalSession()->getToken(); |
347 | $tokenMatch = $token->match( $request->getVal( 'edit_token' ) ); |
348 | $mostActiveWikiDomain = $request->getVal( $this->mostActiveWikiFormField ) ?? ''; |
349 | |
350 | $dbw->newInsertQueryBuilder() |
351 | ->insertInto( 'securepoll_votes' ) |
352 | ->row( [ |
353 | 'vote_election' => $this->election->getId(), |
354 | 'vote_voter' => $this->voter->getId(), |
355 | 'vote_voter_name' => $this->voter->getName(), |
356 | 'vote_voter_domain' => $mostActiveWikiDomain, |
357 | 'vote_record' => $encrypted, |
358 | 'vote_ip' => IPUtils::toHex( $request->getIP() ), |
359 | 'vote_xff' => $xff, |
360 | 'vote_ua' => $_SERVER['HTTP_USER_AGENT'], |
361 | 'vote_timestamp' => $now, |
362 | 'vote_current' => 1, |
363 | 'vote_token_match' => $tokenMatch ? 1 : 0, |
364 | 'vote_struck' => 0, |
365 | 'vote_cookie_dup' => 0, |
366 | ] ) |
367 | ->caller( __METHOD__ ) |
368 | ->execute(); |
369 | $voteId = $dbw->insertId(); |
370 | $dbw->endAtomic( __METHOD__ ); |
371 | |
372 | $votingData = $this->getVoteDataFromRecord( $record ); |
373 | $languageCode = $this->specialPage->getContext()->getLanguage()->getCode(); |
374 | $summary = $this->getSummaryOfVotes( $votingData, $languageCode ); |
375 | $out->addHtml( $summary ); |
376 | |
377 | if ( $crypt ) { |
378 | $receipt = sprintf( "SPID: %10d\n%s", $voteId, $encrypted ); |
379 | $out->addWikiMsg( 'securepoll-gpg-receipt', $receipt ); |
380 | } |
381 | |
382 | $returnUrl = $this->election->getProperty( 'return-url' ); |
383 | if ( $returnUrl ) { |
384 | $returnText = $this->election->getMessage( 'return-text' ); |
385 | if ( $returnText === '' ) { |
386 | $returnText = $returnUrl; |
387 | } |
388 | $link = "[$returnUrl $returnText]"; |
389 | $out->addWikiMsg( 'securepoll-return', $link ); |
390 | } |
391 | } |
392 | |
393 | /** |
394 | * Get a user-readable summary of voting |
395 | * |
396 | * @param array $votingData |
397 | * @param string $languageCode |
398 | * @return string |
399 | */ |
400 | public function getSummaryOfVotes( $votingData, $languageCode ) { |
401 | $data = $votingData['votes']; |
402 | |
403 | // if record cannot be unpacked correctly, show error |
404 | if ( !$data ) { |
405 | return new MessageWidget( [ |
406 | 'type' => 'error', |
407 | 'label' => $this->msg( 'securepoll-vote-result-error-label' ) |
408 | ] ); |
409 | } |
410 | |
411 | $summary = new MessageWidget( [ |
412 | 'type' => 'success', |
413 | 'label' => new HtmlSnippet( $this->msg( 'securepoll-thanks' )->parse() ), |
414 | ] ); |
415 | |
416 | $summary .= Html::element( 'h2', [ 'class' => 'securepoll-vote-result-heading' ], |
417 | $this->msg( 'securepoll-vote-result-intro-label' ) ); |
418 | |
419 | foreach ( $data as $questionIndex => $votes ) { |
420 | $questionMsg = $this->getQuestionMessage( $languageCode, $questionIndex ); |
421 | $optionsMsgs = $this->getOptionMessages( $languageCode, $votes ); |
422 | if ( !isset( $questionMsg[$questionIndex]['text'] ) ) { |
423 | continue; |
424 | } |
425 | $questionText = $questionMsg[$questionIndex]['text']; |
426 | $html = Html::openElement( 'div', [ 'class' => 'securepoll-vote-result-question-cnt' ] ); |
427 | $html .= Html::rawElement( |
428 | 'p', [ 'class' => 'securepoll-vote-result-question' ], |
429 | $this->msg( 'securepoll-vote-result-question-label', $questionText ) |
430 | ); |
431 | |
432 | $votedItems = []; |
433 | if ( $this->election->getTallyType() === 'droop-quota' ) { |
434 | foreach ( $votes as $vote ) { |
435 | // Manually implement Message->escape() to be consistent with the use of msg() |
436 | // in options implemented by other poll types. |
437 | // This is necessary to prevent XSS attacks because STV doesn't use any decorators for |
438 | // its option text displays and therefore doesn't use the msg() function which would |
439 | // have escaped the text as part of its operation. |
440 | $sanitizedText = Sanitizer::escapeCombiningChar( |
441 | htmlspecialchars( $optionsMsgs[$vote]['text'], ENT_QUOTES, 'UTF-8', false ) |
442 | ); |
443 | $bidirectionalText = Html::rawElement( 'bdi', [], $sanitizedText ); |
444 | $votedItems[] = Html::rawElement( 'li', [], $bidirectionalText ); |
445 | } |
446 | $html .= Html::rawElement( 'ol', [ 'class' => 'securepoll-vote-result-options' ], |
447 | implode( "\n", $votedItems ) |
448 | ); |
449 | } else { |
450 | $notVotedItems = []; |
451 | foreach ( $optionsMsgs as $optionIndex => $option ) { |
452 | $optionText = $option['text'] ?? ''; |
453 | $count = $votes[$optionIndex] ?? $optionIndex; |
454 | |
455 | if ( $this->election->getTallyType() === 'plurality' || |
456 | $this->election->getTallyType() === 'histogram-range' ) { |
457 | if ( isset( $questionMsg[$questionIndex]['column' . $count ] ) ) { |
458 | $columnLabel = $questionMsg[$questionIndex]['column' . $count ]; |
459 | $votedItems[] = Html::rawElement( 'li', [], |
460 | $this->msg( 'securepoll-vote-result-voted-option-label', $optionText, $columnLabel ) |
461 | ); |
462 | continue; |
463 | } |
464 | if ( is_int( $count ) && $count > 0 ) { |
465 | $positiveCount = '+' . $count; |
466 | if ( isset( $questionMsg[$questionIndex]['column' . $positiveCount ] ) ) { |
467 | $columnLabel = $questionMsg[$questionIndex]['column' . $positiveCount ]; |
468 | $votedItems[] = Html::rawElement( 'li', [], |
469 | $this->msg( 'securepoll-vote-result-voted-option-label', $optionText, $columnLabel ) |
470 | ); |
471 | continue; |
472 | } |
473 | } |
474 | } |
475 | |
476 | if ( $this->election->getTallyType() === 'schulze' && $count === 1000 ) { |
477 | $notVotedItems[] = Html::rawElement( 'li', [], |
478 | $this->msg( 'securepoll-vote-result-not-voted-option-label', $optionText ) |
479 | ); |
480 | continue; |
481 | } |
482 | |
483 | if ( $count === 0 ) { |
484 | $notVotedItems[] = Html::rawElement( 'li', [], |
485 | $this->msg( 'securepoll-vote-result-not-checked-option-label', $optionText ) |
486 | ); |
487 | continue; |
488 | } |
489 | if ( $this->election->getTallyType() === 'plurality' ) { |
490 | $votedItems[] = Html::rawElement( 'li', [], |
491 | $this->msg( 'securepoll-vote-result-checked-option-label', $optionText ) |
492 | ); |
493 | continue; |
494 | } |
495 | $votedItems[] = Html::rawElement( 'li', [], |
496 | $this->msg( 'securepoll-vote-result-rated-option-label', $optionText, $count ) |
497 | ); |
498 | } |
499 | |
500 | if ( $notVotedItems !== [] ) { |
501 | $votedItems[] = Html::rawElement( 'ul', [ 'class' => 'securepoll-vote-result-no-vote' ], |
502 | implode( "\n", $notVotedItems ) |
503 | ); |
504 | } |
505 | $html .= Html::rawElement( 'ul', [ 'class' => 'securepoll-vote-result-options' ], |
506 | implode( "\n", $votedItems ) |
507 | ); |
508 | } |
509 | $html .= Html::closeElement( 'div' ); |
510 | $summary .= $html; |
511 | } |
512 | |
513 | $comment = $votingData['comment']; |
514 | if ( $comment !== '' ) { |
515 | $summary .= Html::element( 'div', [ 'class' => 'securepoll-vote-result-comment' ], |
516 | $this->msg( 'securepoll-vote-result-comment', $comment )->plain() |
517 | ); |
518 | } |
519 | return $summary; |
520 | } |
521 | |
522 | /** |
523 | * @param string $record |
524 | * @return array |
525 | */ |
526 | public function getVoteDataFromRecord( $record ) { |
527 | $blob = VoteRecord::readBlob( $record ); |
528 | $ballotData = $blob->value->getBallotData(); |
529 | return [ |
530 | 'votes' => $this->getBallot()->unpackRecord( $ballotData ), |
531 | 'comment' => $blob->value->getComment(), |
532 | ]; |
533 | } |
534 | |
535 | /** |
536 | * @param string $languageCode |
537 | * @param int $questionIndex |
538 | * @return string[][] |
539 | */ |
540 | private function getQuestionMessage( $languageCode, $questionIndex ) { |
541 | $questionMsg = $this->context->getMessages( $languageCode, [ $questionIndex ] ); |
542 | if ( !$questionMsg ) { |
543 | $fallbackLangCode = $this->election->getLanguage(); |
544 | $questionMsg = $this->context->getMessages( $fallbackLangCode, [ $questionIndex ] ); |
545 | } |
546 | return $questionMsg; |
547 | } |
548 | |
549 | /** |
550 | * @param string $languageCode |
551 | * @param array $votes |
552 | * @return string[][] |
553 | */ |
554 | private function getOptionMessages( $languageCode, $votes ) { |
555 | $optionsMsgs = $this->context->getMessages( $languageCode, $votes ); |
556 | if ( !$optionsMsgs || count( $votes ) !== count( $optionsMsgs ) ) { |
557 | $languageCode = $this->election->getLanguage(); |
558 | $optionsMsgs = $this->context->getMessages( $languageCode, $votes ); |
559 | } |
560 | if ( !$optionsMsgs || count( $votes ) !== count( $optionsMsgs ) ) { |
561 | $msgsKeys = []; |
562 | foreach ( $votes as $questionKey => $item ) { |
563 | $msgsKeys[] = $questionKey; |
564 | } |
565 | $optionsMsgs = $this->context->getMessages( $languageCode, $msgsKeys ); |
566 | } |
567 | return $optionsMsgs; |
568 | } |
569 | |
570 | /** |
571 | * Show a page informing the user that they must go to another wiki to |
572 | * cast their vote, and a button which takes them there. |
573 | * |
574 | * Clicking the button transmits a hash of their auth token, so that the |
575 | * remote server can authenticate them. |
576 | */ |
577 | public function showJumpForm() { |
578 | $user = $this->specialPage->getUser(); |
579 | $out = $this->specialPage->getOutput(); |
580 | |
581 | $url = $this->election->getProperty( 'jump-url' ); |
582 | if ( ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' ) ) { |
583 | $mobileUrl = $this->election->getProperty( 'mobile-jump-url' ); |
584 | // @phan-suppress-next-line PhanUndeclaredClassMethod |
585 | $mobileContext = MobileContext::singleton(); |
586 | if ( $mobileUrl && $mobileContext->usingMobileDomain() ) { |
587 | $url = $mobileUrl; |
588 | } |
589 | } |
590 | if ( !$url ) { |
591 | throw new InvalidDataException( 'Configuration error: no jump-url' ); |
592 | } |
593 | |
594 | $id = $this->election->getProperty( 'jump-id' ); |
595 | if ( !$id ) { |
596 | throw new InvalidDataException( 'Configuration error: no jump-id' ); |
597 | } |
598 | $url .= "/login/$id"; |
599 | |
600 | $this->hookRunner->onSecurePoll_JumpUrl( $this, $url ); |
601 | |
602 | $out->addWikiTextAsInterface( $this->election->getMessage( 'jump-text' ) ); |
603 | $hiddenFields = [ |
604 | 'token' => RemoteMWAuth::encodeToken( $user->getToken() ), |
605 | 'id' => $user->getId(), |
606 | 'wiki' => WikiMap::getCurrentWikiId(), |
607 | ]; |
608 | |
609 | $htmlForm = HTMLForm::factory( |
610 | 'ooui', |
611 | [], |
612 | $this->specialPage->getContext() |
613 | )->setSubmitTextMsg( 'securepoll-jump' )->setAction( $url )->addHiddenFields( |
614 | $hiddenFields |
615 | )->prepareForm(); |
616 | |
617 | $out->addHTML( $htmlForm->getHTML( false ) ); |
618 | } |
619 | |
620 | /** |
621 | * Show a dropdown of the most active wikis the user has edits on. |
622 | * Filtered by percentage of edits on each wiki, with a threshold configured in SecurePollMostActiveWikisThreshold. |
623 | * This is used to log the domain of the wiki. |
624 | * |
625 | * @return DropdownInputWidget |
626 | */ |
627 | public function createMostActiveWikiDropdownWidget() { |
628 | $options = $this->populateUsersActiveWikiOptions(); |
629 | |
630 | $defaultDomain = $this->voter->getDomain(); |
631 | // First remove value from options if it exists |
632 | $options = array_filter( $options, static function ( $option ) use ( $defaultDomain ) { |
633 | return $option['data'] !== $defaultDomain; |
634 | } ); |
635 | // Then insert default value on top |
636 | array_unshift( $options, [ |
637 | 'label' => $defaultDomain, |
638 | 'data' => $defaultDomain |
639 | ] ); |
640 | |
641 | return new DropdownInputWidget( [ |
642 | 'infusable' => true, |
643 | 'name' => $this->mostActiveWikiFormField, |
644 | 'required' => true, |
645 | 'value' => $defaultDomain, |
646 | 'options' => $options, |
647 | ] ); |
648 | } |
649 | |
650 | /** |
651 | * Populate the dropdown with the most active wikis the user has edits on, |
652 | * based on Central Auth extension. |
653 | * |
654 | * @return array |
655 | */ |
656 | private function populateUsersActiveWikiOptions() { |
657 | // This is a global exception we may want to let it pass. |
658 | // Even though $wgConf is an instance of MediaWiki\Config\SiteConfiguration, |
659 | // it’s not exposed as a service, so accessing it via |
660 | // `MediaWikiServices::getInstance()->getService( 'SiteConfiguration' )` is |
661 | // not possible. |
662 | global $wgConf; |
663 | |
664 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'CentralAuth' ) ) { |
665 | return []; |
666 | } |
667 | |
668 | $user = $this->specialPage->getUser(); |
669 | $centralUser = CentralAuthUser::getInstanceByName( $user->getName() ); |
670 | $wikiInfos = $centralUser->queryAttached(); |
671 | |
672 | // Find and add the corresponding domain |
673 | $wikiInfos = array_map( static function ( $info ) use ( $wgConf ) { |
674 | $info['domain'] = $wgConf->get( 'wgServer', $info['wiki'] ); |
675 | |
676 | return $info; |
677 | }, $wikiInfos ); |
678 | |
679 | // Ensure data integrity |
680 | $wikiInfos = array_filter( $wikiInfos, static function ( $info ) { |
681 | return !empty( $info['wiki'] ) && !empty( $info['editCount'] ) && !empty( $info['domain'] ); |
682 | } ); |
683 | |
684 | $mostActiveWikisThreshold = 0; |
685 | $config = $this->specialPage->getConfig(); |
686 | if ( $config->has( 'SecurePollMostActiveWikisThreshold' ) ) { |
687 | $mostActiveWikisThreshold = $config->get( 'SecurePollMostActiveWikisThreshold' ); |
688 | } |
689 | |
690 | // Filter out wikis with less than $mostActiveWikisThreshold percentage edits |
691 | $allEdits = array_sum( array_column( $wikiInfos, 'editCount' ) ); |
692 | $wikiInfos = array_filter( $wikiInfos, static function ( $info ) use ( $allEdits, $mostActiveWikisThreshold ) { |
693 | return $info['editCount'] / $allEdits * 100 >= $mostActiveWikisThreshold; |
694 | } ); |
695 | |
696 | // Sort by edit count |
697 | usort( $wikiInfos, static function ( $a, $b ) { |
698 | return $b['editCount'] - $a['editCount']; |
699 | } ); |
700 | |
701 | return array_map( function ( $info ) { |
702 | return [ |
703 | 'label' => $this->msg( |
704 | 'securepoll-vote-most-active-wiki-dropdown-option-text', |
705 | $info['wiki'], |
706 | $info['domain'], |
707 | $info['editCount'] |
708 | )->text(), |
709 | 'data' => $info['domain'], |
710 | ]; |
711 | }, $wikiInfos ); |
712 | } |
713 | } |