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