Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
18.06% |
69 / 382 |
|
0.00% |
0 / 14 |
CRAP | |
0.00% |
0 / 1 |
VotePage | |
18.06% |
69 / 382 |
|
0.00% |
0 / 14 |
2844.06 | |
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 | |
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 | 'id' => 'submit-vote-button', |
255 | 'label' => $this->msg( 'securepoll-submit' )->text(), |
256 | 'flags' => [ 'primary', 'progressive' ], |
257 | 'type' => 'submit', |
258 | 'classes' => [ 'submit-vote-button' ], |
259 | 'infusable' => true |
260 | ] |
261 | ) ), |
262 | new HiddenInputWidget( [ |
263 | 'name' => 'edit_token', |
264 | 'value' => SessionManager::getGlobalSession()->getToken()->toString(), |
265 | ] ) |
266 | ] ); |
267 | |
268 | $out->addHTML( $form ); |
269 | } |
270 | |
271 | /** |
272 | * Get the Ballot for this election, with injected request dependencies. |
273 | * @return Ballot |
274 | */ |
275 | private function getBallot() { |
276 | $ballot = $this->election->getBallot(); |
277 | $ballot->initRequest( |
278 | $this->specialPage->getRequest(), |
279 | $this->specialPage, |
280 | $this->getUserLang() |
281 | ); |
282 | return $ballot; |
283 | } |
284 | |
285 | /** |
286 | * Submit the voting form. If successful, a record is added to the database. |
287 | * Shows an error message on failure. |
288 | */ |
289 | public function doSubmit() { |
290 | $ballot = $this->getBallot(); |
291 | $status = $ballot->submitForm(); |
292 | if ( !$status->isOK() ) { |
293 | $this->showForm( $status ); |
294 | } else { |
295 | $voteRecord = VoteRecord::newFromBallotData( |
296 | $status->value, |
297 | $this->specialPage->getRequest()->getText( 'securepoll_comment' ) |
298 | ); |
299 | $this->logVote( $voteRecord->getBlob() ); |
300 | } |
301 | } |
302 | |
303 | /** |
304 | * Add a vote to the database with the given unencrypted answer record. |
305 | * @param string $record |
306 | */ |
307 | public function logVote( $record ) { |
308 | $out = $this->specialPage->getOutput(); |
309 | $request = $this->specialPage->getRequest(); |
310 | |
311 | $now = wfTimestampNow(); |
312 | |
313 | $crypt = $this->election->getCrypt(); |
314 | if ( !$crypt ) { |
315 | $encrypted = $record; |
316 | } else { |
317 | $status = $crypt->encrypt( $record ); |
318 | if ( !$status->isOK() ) { |
319 | $out->addWikiTextAsInterface( $status->getWikiText( 'securepoll-encrypt-error' ) ); |
320 | |
321 | return; |
322 | } |
323 | $encrypted = $status->value; |
324 | } |
325 | |
326 | $dbw = $this->loadBalancer->getConnection( ILoadBalancer::DB_PRIMARY ); |
327 | $dbw->startAtomic( __METHOD__ ); |
328 | |
329 | // Mark previous votes as old |
330 | $dbw->newUpdateQueryBuilder() |
331 | ->update( 'securepoll_votes' ) |
332 | ->set( [ 'vote_current' => 0 ] ) |
333 | ->where( [ |
334 | 'vote_election' => $this->election->getId(), |
335 | 'vote_voter' => $this->voter->getId(), |
336 | ] ) |
337 | ->caller( __METHOD__ ) |
338 | ->execute(); |
339 | |
340 | $xff = ''; |
341 | if ( !empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) { |
342 | $xff = $_SERVER['HTTP_X_FORWARDED_FOR']; |
343 | } |
344 | |
345 | $token = SessionManager::getGlobalSession()->getToken(); |
346 | $tokenMatch = $token->match( $request->getVal( 'edit_token' ) ); |
347 | $mostActiveWikiDomain = $request->getVal( $this->mostActiveWikiFormField ) ?? ''; |
348 | |
349 | $dbw->newInsertQueryBuilder() |
350 | ->insertInto( 'securepoll_votes' ) |
351 | ->row( [ |
352 | 'vote_election' => $this->election->getId(), |
353 | 'vote_voter' => $this->voter->getId(), |
354 | 'vote_voter_name' => $this->voter->getName(), |
355 | 'vote_voter_domain' => $mostActiveWikiDomain, |
356 | 'vote_record' => $encrypted, |
357 | 'vote_ip' => IPUtils::toHex( $request->getIP() ), |
358 | 'vote_xff' => $xff, |
359 | 'vote_ua' => $_SERVER['HTTP_USER_AGENT'], |
360 | 'vote_timestamp' => $now, |
361 | 'vote_current' => 1, |
362 | 'vote_token_match' => $tokenMatch ? 1 : 0, |
363 | 'vote_struck' => 0, |
364 | 'vote_cookie_dup' => 0, |
365 | ] ) |
366 | ->caller( __METHOD__ ) |
367 | ->execute(); |
368 | $voteId = $dbw->insertId(); |
369 | $dbw->endAtomic( __METHOD__ ); |
370 | |
371 | $votingData = $this->getVoteDataFromRecord( $record ); |
372 | $languageCode = $this->specialPage->getContext()->getLanguage()->getCode(); |
373 | $summary = $this->getSummaryOfVotes( $votingData, $languageCode ); |
374 | $out->addHtml( $summary ); |
375 | |
376 | if ( $crypt ) { |
377 | $receipt = sprintf( "SPID: %10d\n%s", $voteId, $encrypted ); |
378 | $out->addWikiMsg( 'securepoll-gpg-receipt', $receipt ); |
379 | } |
380 | |
381 | $returnUrl = $this->election->getProperty( 'return-url' ); |
382 | if ( $returnUrl ) { |
383 | $returnText = $this->election->getMessage( 'return-text' ); |
384 | if ( $returnText === '' ) { |
385 | $returnText = $returnUrl; |
386 | } |
387 | $link = "[$returnUrl $returnText]"; |
388 | $out->addWikiMsg( 'securepoll-return', $link ); |
389 | } |
390 | } |
391 | |
392 | /** |
393 | * Get a user-readable summary of voting |
394 | * |
395 | * @param array $votingData |
396 | * @param string $languageCode |
397 | * @return string |
398 | */ |
399 | public function getSummaryOfVotes( $votingData, $languageCode ) { |
400 | $data = $votingData['votes']; |
401 | |
402 | // if record cannot be unpacked correctly, show error |
403 | if ( !$data ) { |
404 | return new MessageWidget( [ |
405 | 'type' => 'error', |
406 | 'label' => $this->msg( 'securepoll-vote-result-error-label' ) |
407 | ] ); |
408 | } |
409 | |
410 | $summary = new MessageWidget( [ |
411 | 'type' => 'success', |
412 | 'label' => $this->msg( 'securepoll-thanks' ) |
413 | ] ); |
414 | |
415 | $summary .= Html::element( 'h2', [ 'class' => 'securepoll-vote-result-heading' ], |
416 | $this->msg( 'securepoll-vote-result-intro-label' ) ); |
417 | |
418 | foreach ( $data as $questionIndex => $votes ) { |
419 | $questionMsg = $this->getQuestionMessage( $languageCode, $questionIndex ); |
420 | $optionsMsgs = $this->getOptionMessages( $languageCode, $votes ); |
421 | if ( !isset( $questionMsg[$questionIndex]['text'] ) ) { |
422 | continue; |
423 | } |
424 | $questionText = $questionMsg[$questionIndex]['text']; |
425 | $html = Html::openElement( 'div', [ 'class' => 'securepoll-vote-result-question-cnt' ] ); |
426 | $html .= Html::rawElement( |
427 | 'p', [ 'class' => 'securepoll-vote-result-question' ], |
428 | $this->msg( 'securepoll-vote-result-question-label', $questionText ) |
429 | ); |
430 | |
431 | $votedItems = []; |
432 | if ( $this->election->getTallyType() === 'droop-quota' ) { |
433 | foreach ( $votes as $vote ) { |
434 | $votedItems[] = Html::rawElement( 'li', [], $optionsMsgs[$vote]['text'] ); |
435 | } |
436 | $html .= Html::rawElement( 'ol', [ 'class' => 'securepoll-vote-result-options' ], |
437 | implode( "\n", $votedItems ) |
438 | ); |
439 | } else { |
440 | $notVotedItems = []; |
441 | foreach ( $optionsMsgs as $optionIndex => $option ) { |
442 | $optionText = $option['text'] ?? ''; |
443 | $count = $votes[$optionIndex] ?? $optionIndex; |
444 | |
445 | if ( $this->election->getTallyType() === 'plurality' || |
446 | $this->election->getTallyType() === 'histogram-range' ) { |
447 | if ( isset( $questionMsg[$questionIndex]['column' . $count ] ) ) { |
448 | $columnLabel = $questionMsg[$questionIndex]['column' . $count ]; |
449 | $votedItems[] = Html::rawElement( 'li', [], |
450 | $this->msg( 'securepoll-vote-result-voted-option-label', $optionText, $columnLabel ) |
451 | ); |
452 | continue; |
453 | } |
454 | if ( is_int( $count ) && $count > 0 ) { |
455 | $positiveCount = '+' . $count; |
456 | if ( isset( $questionMsg[$questionIndex]['column' . $positiveCount ] ) ) { |
457 | $columnLabel = $questionMsg[$questionIndex]['column' . $positiveCount ]; |
458 | $votedItems[] = Html::rawElement( 'li', [], |
459 | $this->msg( 'securepoll-vote-result-voted-option-label', $optionText, $columnLabel ) |
460 | ); |
461 | continue; |
462 | } |
463 | } |
464 | } |
465 | |
466 | if ( $this->election->getTallyType() === 'schulze' && $count === 1000 ) { |
467 | $notVotedItems[] = Html::rawElement( 'li', [], |
468 | $this->msg( 'securepoll-vote-result-not-voted-option-label', $optionText ) |
469 | ); |
470 | continue; |
471 | } |
472 | |
473 | if ( $count === 0 ) { |
474 | $notVotedItems[] = Html::rawElement( 'li', [], |
475 | $this->msg( 'securepoll-vote-result-not-checked-option-label', $optionText ) |
476 | ); |
477 | continue; |
478 | } |
479 | if ( $this->election->getTallyType() === 'plurality' ) { |
480 | $votedItems[] = Html::rawElement( 'li', [], |
481 | $this->msg( 'securepoll-vote-result-checked-option-label', $optionText ) |
482 | ); |
483 | continue; |
484 | } |
485 | $votedItems[] = Html::rawElement( 'li', [], |
486 | $this->msg( 'securepoll-vote-result-rated-option-label', $optionText, $count ) |
487 | ); |
488 | } |
489 | |
490 | if ( $notVotedItems !== [] ) { |
491 | $votedItems[] = Html::rawElement( 'ul', [ 'class' => 'securepoll-vote-result-no-vote' ], |
492 | implode( "\n", $notVotedItems ) |
493 | ); |
494 | } |
495 | $html .= Html::rawElement( 'ul', [ 'class' => 'securepoll-vote-result-options' ], |
496 | implode( "\n", $votedItems ) |
497 | ); |
498 | } |
499 | $html .= Html::closeElement( 'div' ); |
500 | $summary .= $html; |
501 | } |
502 | |
503 | $comment = $votingData['comment']; |
504 | if ( $comment !== '' ) { |
505 | $summary .= Html::element( 'div', [ 'class' => 'securepoll-vote-result-comment' ], |
506 | $this->msg( 'securepoll-vote-result-comment', $comment )->plain() |
507 | ); |
508 | } |
509 | return $summary; |
510 | } |
511 | |
512 | /** |
513 | * @param string $record |
514 | * @return array |
515 | */ |
516 | public function getVoteDataFromRecord( $record ) { |
517 | $blob = VoteRecord::readBlob( $record ); |
518 | $ballotData = $blob->value->getBallotData(); |
519 | return [ |
520 | 'votes' => $this->getBallot()->unpackRecord( $ballotData ), |
521 | 'comment' => $blob->value->getComment(), |
522 | ]; |
523 | } |
524 | |
525 | /** |
526 | * @param string $languageCode |
527 | * @param int $questionIndex |
528 | * @return string[][] |
529 | */ |
530 | private function getQuestionMessage( $languageCode, $questionIndex ) { |
531 | $questionMsg = $this->context->getMessages( $languageCode, [ $questionIndex ] ); |
532 | if ( !$questionMsg ) { |
533 | $fallbackLangCode = $this->election->getLanguage(); |
534 | $questionMsg = $this->context->getMessages( $fallbackLangCode, [ $questionIndex ] ); |
535 | } |
536 | return $questionMsg; |
537 | } |
538 | |
539 | /** |
540 | * @param string $languageCode |
541 | * @param array $votes |
542 | * @return string[][] |
543 | */ |
544 | private function getOptionMessages( $languageCode, $votes ) { |
545 | $optionsMsgs = $this->context->getMessages( $languageCode, $votes ); |
546 | if ( !$optionsMsgs || count( $votes ) !== count( $optionsMsgs ) ) { |
547 | $languageCode = $this->election->getLanguage(); |
548 | $optionsMsgs = $this->context->getMessages( $languageCode, $votes ); |
549 | } |
550 | if ( !$optionsMsgs || count( $votes ) !== count( $optionsMsgs ) ) { |
551 | $msgsKeys = []; |
552 | foreach ( $votes as $questionKey => $item ) { |
553 | $msgsKeys[] = $questionKey; |
554 | } |
555 | $optionsMsgs = $this->context->getMessages( $languageCode, $msgsKeys ); |
556 | } |
557 | return $optionsMsgs; |
558 | } |
559 | |
560 | /** |
561 | * Show a page informing the user that they must go to another wiki to |
562 | * cast their vote, and a button which takes them there. |
563 | * |
564 | * Clicking the button transmits a hash of their auth token, so that the |
565 | * remote server can authenticate them. |
566 | */ |
567 | public function showJumpForm() { |
568 | $user = $this->specialPage->getUser(); |
569 | $out = $this->specialPage->getOutput(); |
570 | |
571 | $url = $this->election->getProperty( 'jump-url' ); |
572 | if ( ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' ) ) { |
573 | $mobileUrl = $this->election->getProperty( 'mobile-jump-url' ); |
574 | // @phan-suppress-next-line PhanUndeclaredClassMethod |
575 | $mobileContext = MobileContext::singleton(); |
576 | if ( $mobileUrl && $mobileContext->usingMobileDomain() ) { |
577 | $url = $mobileUrl; |
578 | } |
579 | } |
580 | if ( !$url ) { |
581 | throw new InvalidDataException( 'Configuration error: no jump-url' ); |
582 | } |
583 | |
584 | $id = $this->election->getProperty( 'jump-id' ); |
585 | if ( !$id ) { |
586 | throw new InvalidDataException( 'Configuration error: no jump-id' ); |
587 | } |
588 | $url .= "/login/$id"; |
589 | |
590 | $this->hookRunner->onSecurePoll_JumpUrl( $this, $url ); |
591 | |
592 | $out->addWikiTextAsInterface( $this->election->getMessage( 'jump-text' ) ); |
593 | $hiddenFields = [ |
594 | 'token' => RemoteMWAuth::encodeToken( $user->getToken() ), |
595 | 'id' => $user->getId(), |
596 | 'wiki' => WikiMap::getCurrentWikiId(), |
597 | ]; |
598 | |
599 | $htmlForm = HTMLForm::factory( |
600 | 'ooui', |
601 | [], |
602 | $this->specialPage->getContext() |
603 | )->setSubmitTextMsg( 'securepoll-jump' )->setAction( $url )->addHiddenFields( |
604 | $hiddenFields |
605 | )->prepareForm(); |
606 | |
607 | $out->addHTML( $htmlForm->getHTML( false ) ); |
608 | } |
609 | |
610 | /** |
611 | * Show a dropdown of the most active wikis the user has edits on. |
612 | * Filtered by percentage of edits on each wiki, with a threshold configured in SecurePollMostActiveWikisThreshold. |
613 | * This is used to log the domain of the wiki. |
614 | * |
615 | * @return DropdownInputWidget |
616 | */ |
617 | public function createMostActiveWikiDropdownWidget() { |
618 | $options = $this->populateUsersActiveWikiOptions(); |
619 | |
620 | $defaultDomain = $this->voter->getDomain(); |
621 | // First remove value from options if it exists |
622 | $options = array_filter( $options, static function ( $option ) use ( $defaultDomain ) { |
623 | return $option['data'] !== $defaultDomain; |
624 | } ); |
625 | // Then insert default value on top |
626 | array_unshift( $options, [ |
627 | 'label' => $defaultDomain, |
628 | 'data' => $defaultDomain |
629 | ] ); |
630 | |
631 | return new DropdownInputWidget( [ |
632 | 'infusable' => true, |
633 | 'name' => $this->mostActiveWikiFormField, |
634 | 'required' => true, |
635 | 'value' => $defaultDomain, |
636 | 'options' => $options, |
637 | ] ); |
638 | } |
639 | |
640 | /** |
641 | * Populate the dropdown with the most active wikis the user has edits on, |
642 | * based on Central Auth extension. |
643 | * |
644 | * @return array |
645 | */ |
646 | private function populateUsersActiveWikiOptions() { |
647 | // This is a global exception we may want to let it pass. |
648 | // Even though $wgConf is an instance of MediaWiki\Config\SiteConfiguration, |
649 | // it’s not exposed as a service, so accessing it via |
650 | // `MediaWikiServices::getInstance()->getService( 'SiteConfiguration' )` is |
651 | // not possible. |
652 | global $wgConf; |
653 | |
654 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'CentralAuth' ) ) { |
655 | return []; |
656 | } |
657 | |
658 | $user = $this->specialPage->getUser(); |
659 | $centralUser = CentralAuthUser::getInstanceByName( $user->getName() ); |
660 | $wikiInfos = $centralUser->queryAttached(); |
661 | |
662 | // Find and add the corresponding domain |
663 | $wikiInfos = array_map( static function ( $info ) use ( $wgConf ) { |
664 | $info['domain'] = $wgConf->get( 'wgServer', $info['wiki'] ); |
665 | |
666 | return $info; |
667 | }, $wikiInfos ); |
668 | |
669 | // Ensure data integrity |
670 | $wikiInfos = array_filter( $wikiInfos, static function ( $info ) { |
671 | return !empty( $info['wiki'] ) && !empty( $info['editCount'] ) && !empty( $info['domain'] ); |
672 | } ); |
673 | |
674 | $mostActiveWikisThreshold = 0; |
675 | $config = $this->specialPage->getConfig(); |
676 | if ( $config->has( 'SecurePollMostActiveWikisThreshold' ) ) { |
677 | $mostActiveWikisThreshold = $config->get( 'SecurePollMostActiveWikisThreshold' ); |
678 | } |
679 | |
680 | // Filter out wikis with less than $mostActiveWikisThreshold percentage edits |
681 | $allEdits = array_sum( array_column( $wikiInfos, 'editCount' ) ); |
682 | $wikiInfos = array_filter( $wikiInfos, static function ( $info ) use ( $allEdits, $mostActiveWikisThreshold ) { |
683 | return $info['editCount'] / $allEdits * 100 >= $mostActiveWikisThreshold; |
684 | } ); |
685 | |
686 | // Sort by edit count |
687 | usort( $wikiInfos, static function ( $a, $b ) { |
688 | return $b['editCount'] - $a['editCount']; |
689 | } ); |
690 | |
691 | return array_map( function ( $info ) { |
692 | return [ |
693 | 'label' => $this->msg( |
694 | 'securepoll-vote-most-active-wiki-dropdown-option-text', |
695 | $info['wiki'], |
696 | $info['domain'], |
697 | $info['editCount'] |
698 | )->text(), |
699 | 'data' => $info['domain'], |
700 | ]; |
701 | }, $wikiInfos ); |
702 | } |
703 | } |