Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 193
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
TallyPage
0.00% covered (danger)
0.00%
0 / 193
0.00% covered (danger)
0.00%
0 / 13
1260
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 / 24
0.00% covered (danger)
0.00%
0 / 1
30
 showTallyError
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 isTallyEnqueued
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 showTallyStatus
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 showTallyResult
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 createForm
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
 getCryptDescriptors
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 submitForm
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 submitUpload
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 submitJob
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
6
 updateContextForCrypt
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Pages;
4
5use JobQueueGroup;
6use JobSpecification;
7use MediaWiki\Extension\SecurePoll\Context;
8use MediaWiki\Extension\SecurePoll\Entities\Election;
9use MediaWiki\Extension\SecurePoll\SpecialSecurePoll;
10use MediaWiki\Extension\SecurePoll\Store\MemoryStore;
11use MediaWiki\Extension\SecurePoll\Talliers\ElectionTallier;
12use MediaWiki\HTMLForm\HTMLForm;
13use MediaWiki\HTMLForm\OOUIHTMLForm;
14use MediaWiki\Request\WebRequestUpload;
15use MediaWiki\Status\Status;
16use MediaWiki\Title\Title;
17use OOUI\MessageWidget;
18use RuntimeException;
19use Wikimedia\Rdbms\ILoadBalancer;
20
21/**
22 * A subpage for tallying votes and producing results
23 */
24class TallyPage extends ActionPage {
25    /** @var ILoadBalancer */
26    private $loadBalancer;
27
28    /** @var JobQueueGroup */
29    private $jobQueueGroup;
30
31    /** @var bool */
32    private $tallyEnqueued = null;
33
34    /**
35     * @param SpecialSecurePoll $specialPage
36     * @param ILoadBalancer $loadBalancer
37     * @param JobQueueGroup $jobQueueGroup
38     */
39    public function __construct(
40        SpecialSecurePoll $specialPage,
41        ILoadBalancer $loadBalancer,
42        JobQueueGroup $jobQueueGroup
43    ) {
44        parent::__construct( $specialPage );
45        $this->loadBalancer = $loadBalancer;
46        $this->jobQueueGroup = $jobQueueGroup;
47    }
48
49    /**
50     * Execute the subpage.
51     * @param array $params Array of subpage parameters.
52     */
53    public function execute( $params ) {
54        $out = $this->specialPage->getOutput();
55        $out->enableOOUI();
56
57        if ( !count( $params ) ) {
58            $out->addWikiMsg( 'securepoll-too-few-params' );
59            return;
60        }
61
62        $electionId = intval( $params[0] );
63        $this->election = $this->context->getElection( $electionId );
64        if ( !$this->election ) {
65            $out->addWikiMsg( 'securepoll-invalid-election', $electionId );
66            return;
67        }
68
69        $user = $this->specialPage->getUser();
70        $this->initLanguage( $user, $this->election );
71        $out->setPageTitleMsg( $this->msg( 'securepoll-tally-title', $this->election->getMessage( 'title' ) ) );
72
73        if ( !$this->election->isAdmin( $user ) ) {
74            $out->addWikiMsg( 'securepoll-need-admin' );
75            return;
76        }
77
78        if ( !$this->election->isFinished() ) {
79            $out->addWikiMsg( 'securepoll-tally-not-finished' );
80            return;
81        }
82
83        $this->showTallyStatus();
84
85        $form = $this->createForm();
86        $form->show();
87
88        $this->showTallyError();
89        $this->showTallyResult();
90    }
91
92    /**
93     * Show any errors from the most recent tally attempt
94     */
95    private function showTallyError(): void {
96        $dbr = $this->loadBalancer->getConnection( ILoadBalancer::DB_REPLICA );
97        $out = $this->specialPage->getOutput();
98
99        $result = $dbr->newSelectQueryBuilder()
100            ->select( 'pr_value' )
101            ->from( 'securepoll_properties' )
102            ->where( [
103                'pr_entity' => $this->election->getId(),
104                'pr_key' => 'tally-error',
105            ] )
106            ->caller( __METHOD__ )
107            ->fetchField();
108
109        if ( $result ) {
110            $message = new MessageWidget( [
111                'label' => $this->msg( 'securepoll-tally-result-error', $result )->text(),
112                'type' => 'error',
113            ] );
114            $out->prependHTML( $message->toString() );
115        }
116    }
117
118    /**
119     * Check whether there is enqueued tally
120     *
121     * @return bool
122     */
123    private function isTallyEnqueued(): bool {
124        if ( $this->tallyEnqueued !== null ) {
125            return $this->tallyEnqueued;
126        }
127
128        $dbr = $this->loadBalancer->getConnection( ILoadBalancer::DB_REPLICA );
129        $result = $dbr->newSelectQueryBuilder()
130            ->select( 'pr_value' )
131            ->from( 'securepoll_properties' )
132            ->where( [
133                'pr_entity' => $this->election->getId(),
134                'pr_key' => 'tally-job-enqueued',
135            ] )
136            ->caller( __METHOD__ )
137            ->fetchField();
138        $this->tallyEnqueued = (bool)$result;
139        return $this->tallyEnqueued;
140    }
141
142    /**
143     * Show messages indicating the status of tallying if relevant
144     */
145    private function showTallyStatus(): void {
146        $dbr = $this->loadBalancer->getConnection( ILoadBalancer::DB_REPLICA );
147        $out = $this->specialPage->getOutput();
148
149        $result = $dbr->newSelectQueryBuilder()
150            ->select( 'pr_value' )
151            ->from( 'securepoll_properties' )
152            ->where( [
153                'pr_entity' => $this->election->getId(),
154                'pr_key' => 'tally-job-enqueued',
155            ] )
156            ->caller( __METHOD__ )
157            ->fetchField();
158
159        if ( $result ) {
160            $message = new MessageWidget( [
161                'label' => $this->msg( 'securepoll-tally-job-enqueued' )->text(),
162                'type' => 'warning',
163            ] );
164            $out->addHTML( $message->toString() );
165        }
166    }
167
168    /**
169     * Show the tally result if one has previously been calculated
170     */
171    private function showTallyResult(): void {
172        $dbr = $this->loadBalancer->getConnection( ILoadBalancer::DB_REPLICA );
173        $out = $this->specialPage->getOutput();
174
175        $tallier = $this->election->getTallyFromDb( $dbr );
176        if ( !$tallier ) {
177            return;
178        }
179
180        $time = $this->election->getTallyResultTimeFromDb( $dbr );
181
182        $out->addHTML(
183            $out->msg( 'securepoll-tally-result' )
184                ->rawParams( $tallier->getHtmlResult() )
185                ->dateTimeParams( wfTimestamp( TS_UNIX, $time ) )
186        );
187    }
188
189    /**
190     * Create a form which, when submitted, shows a tally for the election.
191     *
192     * @return OOUIHTMLForm
193     */
194    private function createForm() {
195        $formFields = $this->getCryptDescriptors();
196
197        if ( $this->isTallyEnqueued() ) {
198            foreach ( $formFields as $fieldname => $field ) {
199                $formFields[$fieldname]['disabled'] = true;
200            }
201            // This will replace the default submit button
202            $formFields['disabledSubmit'] = [
203                'type' => 'submit',
204                'disabled' => true,
205                'buttonlabel-message' => 'securepoll-tally-upload-submit',
206            ];
207        }
208
209        $form = HTMLForm::factory(
210            'ooui',
211            $formFields,
212            $this->specialPage->getContext(),
213            'securepoll-tally'
214        );
215
216        $form->setSubmitTextMsg( 'securepoll-tally-upload-submit' )
217            ->setSubmitCallback( [ $this, 'submitForm' ] );
218
219        if ( $this->isTallyEnqueued() ) {
220            $form->suppressDefaultSubmit();
221        }
222
223        if ( $this->election->getCrypt() ) {
224            $form->setWrapperLegend( true );
225        }
226
227        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
228        return $form;
229    }
230
231    /**
232     * Get any crypt-specific descriptors for the form.
233     *
234     * @return array
235     */
236    private function getCryptDescriptors(): array {
237        $crypt = $this->election->getCrypt();
238
239        if ( !$crypt ) {
240            return [];
241        }
242        $formFields = [];
243        if ( !$crypt->canDecrypt() ) {
244            $formFields += $crypt->getTallyDescriptors();
245        }
246
247        $formFields += [
248            'tally_file' => [
249                'type' => 'file',
250                'name' => 'tally_file',
251                'help-message' => 'securepoll-tally-form-file-help',
252                'label-message' => 'securepoll-tally-form-file-label',
253            ],
254        ];
255
256        return $formFields;
257    }
258
259    /**
260     * Show a tally, either from the database or from an uploaded file.
261     *
262     * @internal Submit callback for the HTMLForm
263     * @param array $data Data from the form fields
264     * @return bool|string|array|Status As documented for HTMLForm::trySubmit
265     */
266    public function submitForm( array $data ) {
267        $upload = $this->specialPage->getRequest()->getUpload( 'tally_file' );
268        if ( !$upload->exists()
269            || !is_uploaded_file( $upload->getTempName() )
270            || !$upload->getSize()
271        ) {
272            return $this->submitJob( $this->election, $data );
273        }
274        return $this->submitUpload( $data, $upload );
275    }
276
277    /**
278     * Show a tally of the results in the uploaded file
279     *
280     * @param array $data Data from the form fields
281     * @param WebRequestUpload $upload
282     * @return bool|string|array|Status As documented for HTMLForm::trySubmit
283     */
284    private function submitUpload( array $data, WebRequestUpload $upload ) {
285        $out = $this->specialPage->getOutput();
286
287        $context = Context::newFromXmlFile( $upload->getTempName() );
288        if ( !$context ) {
289            return [ 'securepoll-dump-corrupt' ];
290        }
291        $store = $context->getStore();
292        if ( !$store instanceof MemoryStore ) {
293            $class = get_class( $store );
294            throw new RuntimeException(
295                "Expected instance of MemoryStore, got $class instead"
296            );
297        }
298        $electionIds = $store->getAllElectionIds();
299        $election = $context->getElection( reset( $electionIds ) );
300
301        $this->updateContextForCrypt( $election, $data );
302
303        $status = $election->tally();
304        if ( !$status->isOK() ) {
305            return [ [ 'securepoll-tally-upload-error', $status->getMessage() ] ];
306        }
307        $tallier = $status->value;
308        '@phan-var ElectionTallier $tallier'; /** @var ElectionTallier $tallier */
309        $out->addHTML( $tallier->getHtmlResult() );
310        return true;
311    }
312
313    /**
314     * @param Election $election
315     * @param array $data
316     * @return bool
317     */
318    private function submitJob( Election $election, array $data ): bool {
319        $electionId = $election->getId();
320        $dbw = $this->loadBalancer->getConnection( ILoadBalancer::DB_PRIMARY );
321
322        $crypt = $election->getCrypt();
323        if ( $crypt ) {
324            // Save any request data that is needed for tallying
325            $election->getCrypt()->updateDbForTallyJob( $electionId, $dbw, $data );
326        }
327
328        // Record that the election is being tallied. The job will
329        // delete this on completion.
330        $dbw->newInsertQueryBuilder()
331            ->insertInto( 'securepoll_properties' )
332            ->row( [
333                'pr_entity' => $electionId,
334                'pr_key' => 'tally-job-enqueued',
335                'pr_value' => 1,
336            ] )
337            ->onDuplicateKeyUpdate()
338            ->uniqueIndexFields( [ 'pr_entity', 'pr_key' ] )
339            ->set( [
340                'pr_entity' => $electionId,
341                'pr_key' => 'tally-job-enqueued',
342            ] )
343            ->caller( __METHOD__ )
344            ->execute();
345
346        $this->jobQueueGroup->push(
347            new JobSpecification(
348                'securePollTallyElection',
349                [ 'electionId' => $electionId ],
350                [],
351                $this->getTitle()
352            )
353        );
354
355        // Delete error to prevent showing old errors while job is queueing
356        $dbw->newDeleteQueryBuilder()
357            ->deleteFrom( 'securepoll_properties' )
358            ->where( [
359                'pr_entity' => $electionId,
360                'pr_key' => 'tally-error',
361            ] )
362            ->caller( __METHOD__ )
363            ->execute();
364
365        // Redirect (using HTTP 303 See Other) the UA to the current URL so that the user does not
366        // inadvertently resubmit the form while trying to determine if the tallier has finished.
367        $url = $this->getTitle()
368            ->getFullURL();
369
370        $this->specialPage->getOutput()
371            ->redirect( $url, 303 );
372
373        return true;
374    }
375
376    /**
377     * Update the context of the election to be tallied with any information
378     * not stored in the database that is needed for decryption.
379     *
380     * @param Election $election The election to be tallied
381     * @param array $data Form data
382     */
383    private function updateContextForCrypt( Election $election, array $data ): void {
384        $crypt = $election->getCrypt();
385        if ( $crypt ) {
386            $crypt->updateTallyContext( $election->context, $data );
387        }
388    }
389
390    /**
391     * @return Title
392     */
393    public function getTitle() {
394        return $this->specialPage->getPageTitle( 'tally/' . $this->election->getId() );
395    }
396}