Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
17.88% covered (danger)
17.88%
69 / 386
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
VotePage
17.88% covered (danger)
17.88%
69 / 386
0.00% covered (danger)
0.00%
0 / 14
2863.11
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
79.31% covered (warning)
79.31%
69 / 87
0.00% covered (danger)
0.00%
0 / 1
22.20
 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\Parser\Sanitizer;
19use MediaWiki\Registration\ExtensionRegistry;
20use MediaWiki\Session\SessionManager;
21use MediaWiki\Status\Status;
22use MediaWiki\Title\Title;
23use MediaWiki\User\User;
24use MediaWiki\WikiMap\WikiMap;
25use MobileContext;
26use OOUI\ButtonInputWidget;
27use OOUI\DropdownInputWidget;
28use OOUI\FieldLayout;
29use OOUI\FieldsetLayout;
30use OOUI\FormLayout;
31use OOUI\HiddenInputWidget;
32use OOUI\HtmlSnippet;
33use OOUI\MessageWidget;
34use OOUI\MultilineTextInputWidget;
35use Wikimedia\IPUtils;
36use Wikimedia\Rdbms\ILoadBalancer;
37
38/**
39 * The subpage for casting votes.
40 */
41class 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}