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