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