Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
18.06% covered (danger)
18.06%
69 / 382
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
VotePage
18.06% covered (danger)
18.06%
69 / 382
0.00% covered (danger)
0.00%
0 / 14
2844.06
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 61
0.00% covered (danger)
0.00%
0 / 1
182
 getTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showForm
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
30
 getBallot
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 doSubmit
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 logVote
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
72
 getSummaryOfVotes
83.13% covered (warning)
83.13%
69 / 83
0.00% covered (danger)
0.00%
0 / 1
20.73
 getVoteDataFromRecord
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getQuestionMessage
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getOptionMessages
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 showJumpForm
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
42
 createMostActiveWikiDropdownWidget
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 populateUsersActiveWikiOptions
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Pages;
4
5use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
6use MediaWiki\Extension\SecurePoll\Ballots\Ballot;
7use MediaWiki\Extension\SecurePoll\Entities\Election;
8use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException;
9use MediaWiki\Extension\SecurePoll\Hooks\HookRunner;
10use MediaWiki\Extension\SecurePoll\SpecialSecurePoll;
11use MediaWiki\Extension\SecurePoll\User\Auth;
12use MediaWiki\Extension\SecurePoll\User\RemoteMWAuth;
13use MediaWiki\Extension\SecurePoll\User\Voter;
14use MediaWiki\Extension\SecurePoll\VoteRecord;
15use MediaWiki\HookContainer\HookContainer;
16use MediaWiki\Html\Html;
17use MediaWiki\HTMLForm\HTMLForm;
18use MediaWiki\Registration\ExtensionRegistry;
19use MediaWiki\Session\SessionManager;
20use MediaWiki\Status\Status;
21use MediaWiki\Title\Title;
22use MediaWiki\User\User;
23use MediaWiki\WikiMap\WikiMap;
24use MobileContext;
25use OOUI\ButtonInputWidget;
26use OOUI\DropdownInputWidget;
27use OOUI\FieldLayout;
28use OOUI\FieldsetLayout;
29use OOUI\FormLayout;
30use OOUI\HiddenInputWidget;
31use OOUI\HtmlSnippet;
32use OOUI\MessageWidget;
33use OOUI\MultilineTextInputWidget;
34use Wikimedia\IPUtils;
35use Wikimedia\Rdbms\ILoadBalancer;
36
37/**
38 * The subpage for casting votes.
39 */
40class 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}