Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 201
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 / 201
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 / 19
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 OOUI\MessageWidget;
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' => 'tally-error',
104            ] )
105            ->caller( __METHOD__ )
106            ->fetchField();
107
108        if ( $result ) {
109            $message = new MessageWidget( [
110                'label' => $this->msg( 'securepoll-tally-result-error', $result )->text(),
111                'type' => 'error',
112            ] );
113            $out->prependHTML( $message->toString() );
114        }
115    }
116
117    /**
118     * Check whether there is enqueued tally
119     *
120     * @return bool
121     */
122    private function isTallyEnqueued(): bool {
123        if ( $this->tallyEnqueued !== null ) {
124            return $this->tallyEnqueued;
125        }
126
127        $dbr = $this->loadBalancer->getConnection( ILoadBalancer::DB_REPLICA );
128        $result = $dbr->newSelectQueryBuilder()
129            ->select( 'pr_value' )
130            ->from( 'securepoll_properties' )
131            ->where( [
132                'pr_entity' => $this->election->getId(),
133                'pr_key' => 'tally-job-enqueued',
134            ] )
135            ->caller( __METHOD__ )
136            ->fetchField();
137        $this->tallyEnqueued = (bool)$result;
138        return $this->tallyEnqueued;
139    }
140
141    /**
142     * Show messages indicating the status of tallying if relevant
143     */
144    private function showTallyStatus(): void {
145        $dbr = $this->loadBalancer->getConnection( ILoadBalancer::DB_REPLICA );
146        $out = $this->specialPage->getOutput();
147
148        $result = $dbr->newSelectQueryBuilder()
149            ->select( 'pr_value' )
150            ->from( 'securepoll_properties' )
151            ->where( [
152                'pr_entity' => $this->election->getId(),
153                'pr_key' => 'tally-job-enqueued',
154            ] )
155            ->caller( __METHOD__ )
156            ->fetchField();
157
158        if ( $result ) {
159            $message = new MessageWidget( [
160                'label' => $this->msg( 'securepoll-tally-job-enqueued' )->text(),
161                'type' => 'warning',
162            ] );
163            $out->addHTML( $message->toString() );
164        }
165    }
166
167    /**
168     * Show the tally result if one has previously been calculated
169     */
170    private function showTallyResult(): void {
171        $dbr = $this->loadBalancer->getConnection( ILoadBalancer::DB_REPLICA );
172        $out = $this->specialPage->getOutput();
173
174        $tallier = $this->election->getTallyFromDb( $dbr );
175
176        if ( !$tallier ) {
177            return;
178        }
179
180        $time = $dbr->newSelectQueryBuilder()
181            ->select( 'pr_value' )
182            ->from( 'securepoll_properties' )
183            ->where( [
184                'pr_entity' => $this->election->getId(),
185                'pr_key' => 'tally-result-time',
186            ] )
187            ->caller( __METHOD__ )
188            ->fetchField();
189
190        $out->addHTML(
191            $out->msg( 'securepoll-tally-result' )
192                ->rawParams( $tallier->getHtmlResult() )
193                ->dateTimeParams( wfTimestamp( TS_UNIX, $time ) )
194        );
195    }
196
197    /**
198     * Create a form which, when submitted, shows a tally for the election.
199     *
200     * @return OOUIHTMLForm
201     */
202    private function createForm() {
203        $formFields = $this->getCryptDescriptors();
204
205        if ( $this->isTallyEnqueued() ) {
206            foreach ( $formFields as $fieldname => $field ) {
207                $formFields[$fieldname]['disabled'] = true;
208            }
209            // This will replace the default submit button
210            $formFields['disabledSubmit'] = [
211                'type' => 'submit',
212                'disabled' => true,
213                'buttonlabel-message' => 'securepoll-tally-upload-submit',
214            ];
215        }
216
217        $form = HTMLForm::factory(
218            'ooui',
219            $formFields,
220            $this->specialPage->getContext(),
221            'securepoll-tally'
222        );
223
224        $form->setSubmitTextMsg( 'securepoll-tally-upload-submit' )
225            ->setSubmitCallback( [ $this, 'submitForm' ] );
226
227        if ( $this->isTallyEnqueued() ) {
228            $form->suppressDefaultSubmit();
229        }
230
231        if ( $this->election->getCrypt() ) {
232            $form->setWrapperLegend( true );
233        }
234
235        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
236        return $form;
237    }
238
239    /**
240     * Get any crypt-specific descriptors for the form.
241     *
242     * @return array
243     */
244    private function getCryptDescriptors(): array {
245        $crypt = $this->election->getCrypt();
246
247        if ( !$crypt ) {
248            return [];
249        }
250        $formFields = [];
251        if ( !$crypt->canDecrypt() ) {
252            $formFields += $crypt->getTallyDescriptors();
253        }
254
255        $formFields += [
256            'tally_file' => [
257                'type' => 'file',
258                'name' => 'tally_file',
259                'help-message' => 'securepoll-tally-form-file-help',
260                'label-message' => 'securepoll-tally-form-file-label',
261            ],
262        ];
263
264        return $formFields;
265    }
266
267    /**
268     * Show a tally, either from the database or from an uploaded file.
269     *
270     * @internal Submit callback for the HTMLForm
271     * @param array $data Data from the form fields
272     * @return bool|string|array|Status As documented for HTMLForm::trySubmit
273     */
274    public function submitForm( array $data ) {
275        $upload = $this->specialPage->getRequest()->getUpload( 'tally_file' );
276        if ( !$upload->exists()
277            || !is_uploaded_file( $upload->getTempName() )
278            || !$upload->getSize()
279        ) {
280            return $this->submitJob( $this->election, $data );
281        }
282        return $this->submitUpload( $data, $upload );
283    }
284
285    /**
286     * Show a tally of the results in the uploaded file
287     *
288     * @param array $data Data from the form fields
289     * @param WebRequestUpload $upload
290     * @return bool|string|array|Status As documented for HTMLForm::trySubmit
291     */
292    private function submitUpload( array $data, WebRequestUpload $upload ) {
293        $out = $this->specialPage->getOutput();
294
295        $context = Context::newFromXmlFile( $upload->getTempName() );
296        if ( !$context ) {
297            return [ 'securepoll-dump-corrupt' ];
298        }
299        $store = $context->getStore();
300        if ( !$store instanceof MemoryStore ) {
301            $class = get_class( $store );
302            throw new RuntimeException(
303                "Expected instance of MemoryStore, got $class instead"
304            );
305        }
306        $electionIds = $store->getAllElectionIds();
307        $election = $context->getElection( reset( $electionIds ) );
308
309        $this->updateContextForCrypt( $election, $data );
310
311        $status = $election->tally();
312        if ( !$status->isOK() ) {
313            return [ [ 'securepoll-tally-upload-error', $status->getMessage() ] ];
314        }
315        $tallier = $status->value;
316        '@phan-var ElectionTallier $tallier'; /** @var ElectionTallier $tallier */
317        $out->addHTML( $tallier->getHtmlResult() );
318        return true;
319    }
320
321    /**
322     * @param Election $election
323     * @param array $data
324     * @return bool
325     */
326    private function submitJob( Election $election, array $data ): bool {
327        $electionId = $election->getId();
328        $dbw = $this->loadBalancer->getConnection( ILoadBalancer::DB_PRIMARY );
329
330        $crypt = $election->getCrypt();
331        if ( $crypt ) {
332            // Save any request data that is needed for tallying
333            $election->getCrypt()->updateDbForTallyJob( $electionId, $dbw, $data );
334        }
335
336        // Record that the election is being tallied. The job will
337        // delete this on completion.
338        $dbw->newInsertQueryBuilder()
339            ->insertInto( 'securepoll_properties' )
340            ->row( [
341                'pr_entity' => $electionId,
342                'pr_key' => 'tally-job-enqueued',
343                'pr_value' => 1,
344            ] )
345            ->onDuplicateKeyUpdate()
346            ->uniqueIndexFields( [ 'pr_entity', 'pr_key' ] )
347            ->set( [
348                'pr_entity' => $electionId,
349                'pr_key' => 'tally-job-enqueued',
350            ] )
351            ->caller( __METHOD__ )
352            ->execute();
353
354        $this->jobQueueGroup->push(
355            new JobSpecification(
356                'securePollTallyElection',
357                [ 'electionId' => $electionId ],
358                [],
359                $this->getTitle()
360            )
361        );
362
363        // Delete error to prevent showing old errors while job is queueing
364        $dbw->newDeleteQueryBuilder()
365            ->deleteFrom( 'securepoll_properties' )
366            ->where( [
367                'pr_entity' => $electionId,
368                'pr_key' => 'tally-error',
369            ] )
370            ->caller( __METHOD__ )
371            ->execute();
372
373        // Redirect (using HTTP 303 See Other) the UA to the current URL so that the user does not
374        // inadvertently resubmit the form while trying to determine if the tallier has finished.
375        $url = $this->getTitle()
376            ->getFullURL();
377
378        $this->specialPage->getOutput()
379            ->redirect( $url, 303 );
380
381        return true;
382    }
383
384    /**
385     * Update the context of the election to be tallied with any information
386     * not stored in the database that is needed for decryption.
387     *
388     * @param Election $election The election to be tallied
389     * @param array $data Form data
390     */
391    private function updateContextForCrypt( Election $election, array $data ): void {
392        $crypt = $election->getCrypt();
393        if ( $crypt ) {
394            $crypt->updateTallyContext( $election->context, $data );
395        }
396    }
397
398    public function getTitle() {
399        return $this->specialPage->getPageTitle( 'tally/' . $this->election->getId() );
400    }
401}