Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 888
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
CreatePage
0.00% covered (danger)
0.00%
0 / 888
0.00% covered (danger)
0.00%
0 / 14
19460
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 389
0.00% covered (danger)
0.00%
0 / 1
2070
 processInputDuringElection
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 processInput
0.00% covered (danger)
0.00%
0 / 236
0.00% covered (danger)
0.00%
0 / 1
600
 recordElectionToNamespace
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
6
 logAdminChanges
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 getFormDataFromElection
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 1
380
 insertEntity
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 savePropertiesAndMessages
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
56
 processFormItems
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
110
 unprocessFormData
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
210
 checkEditPollRight
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 checkElectionEndDate
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 checkRequired
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Pages;
4
5use DateTime;
6use DateTimeZone;
7use MediaWiki\Extension\SecurePoll\Ballots\Ballot;
8use MediaWiki\Extension\SecurePoll\Context;
9use MediaWiki\Extension\SecurePoll\Crypt\Crypt;
10use MediaWiki\Extension\SecurePoll\Entities\Entity;
11use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException;
12use MediaWiki\Extension\SecurePoll\SecurePollContentHandler;
13use MediaWiki\Extension\SecurePoll\SpecialSecurePoll;
14use MediaWiki\Extension\SecurePoll\Store\FormStore;
15use MediaWiki\Extension\SecurePoll\Talliers\Tallier;
16use MediaWiki\HTMLForm\HTMLForm;
17use MediaWiki\Language\LanguageCode;
18use MediaWiki\Languages\LanguageNameUtils;
19use MediaWiki\Linker\Linker;
20use MediaWiki\Message\Message;
21use MediaWiki\Page\WikiPageFactory;
22use MediaWiki\SpecialPage\SpecialPage;
23use MediaWiki\Status\Status;
24use MediaWiki\User\UserFactory;
25use MediaWiki\Utils\MWTimestamp;
26use MediaWiki\WikiMap\WikiMap;
27use PermissionsError;
28use Wikimedia\Rdbms\IDatabase;
29use Wikimedia\Rdbms\ILoadBalancer;
30use Wikimedia\Rdbms\LBFactory;
31
32/**
33 * Special:SecurePoll subpage for creating or editing a poll
34 */
35class CreatePage extends ActionPage {
36    /** @var LBFactory */
37    private $lbFactory;
38
39    /** @var LanguageNameUtils */
40    private $languageNameUtils;
41
42    /** @var WikiPageFactory */
43    private $wikiPageFactory;
44
45    /** @var UserFactory */
46    private $userFactory;
47
48    public function __construct(
49        SpecialSecurePoll $specialPage,
50        LBFactory $lbFactory,
51        LanguageNameUtils $languageNameUtils,
52        WikiPageFactory $wikiPageFactory,
53        UserFactory $userFactory
54    ) {
55        parent::__construct( $specialPage );
56        $this->lbFactory = $lbFactory;
57        $this->languageNameUtils = $languageNameUtils;
58        $this->wikiPageFactory = $wikiPageFactory;
59        $this->userFactory = $userFactory;
60    }
61
62    /**
63     * Execute the subpage.
64     * @param array $params Array of subpage parameters.
65     * @throws InvalidDataException
66     * @throws PermissionsError
67     */
68    public function execute( $params ) {
69        $out = $this->specialPage->getOutput();
70
71        if ( $params ) {
72            $out->setPageTitleMsg( $this->msg( 'securepoll-edit-title' ) );
73            $electionId = intval( $params[0] );
74            $this->election = $this->context->getElection( $electionId );
75            if ( !$this->election ) {
76                $out->addWikiMsg( 'securepoll-invalid-election', $electionId );
77
78                return;
79            }
80            if ( !$this->election->isAdmin( $this->specialPage->getUser() ) ) {
81                $out->addWikiMsg( 'securepoll-need-admin' );
82
83                return;
84            }
85            if ( $this->election->isFinished() ) {
86                $out->addWikiMsg( 'securepoll-finished-no-edit' );
87                return;
88            }
89
90            $jumpUrl = $this->election->getProperty( 'jump-url' );
91            if ( $jumpUrl ) {
92                $jumpId = $this->election->getProperty( 'jump-id' );
93                if ( !$jumpId ) {
94                    throw new InvalidDataException( 'Configuration error: no jump-id' );
95                }
96                $jumpUrl .= "/edit/$jumpId";
97                if ( count( $params ) > 1 ) {
98                    $jumpUrl .= '/' . implode( '/', array_slice( $params, 1 ) );
99                }
100
101                $wiki = $this->election->getProperty( 'main-wiki' );
102                if ( $wiki ) {
103                    $wiki = WikiMap::getWikiName( $wiki );
104                } else {
105                    $wiki = $this->msg( 'securepoll-edit-redirect-otherwiki' )->text();
106                }
107
108                $out->addWikiMsg(
109                    'securepoll-edit-redirect',
110                    Message::rawParam( Linker::makeExternalLink( $jumpUrl, $wiki ) )
111                );
112
113                return;
114            }
115        } else {
116            $out->setPageTitleMsg( $this->msg( 'securepoll-create-title' ) );
117            if ( !$this->specialPage->getUser()->isAllowed( 'securepoll-create-poll' ) ) {
118                throw new PermissionsError( 'securepoll-create-poll' );
119            }
120        }
121
122        $out->addJsConfigVars( 'SecurePollSubPage', 'create' );
123        $out->addModules( 'ext.securepoll.htmlform' );
124        $out->addModuleStyles( [
125            'mediawiki.widgets.TagMultiselectWidget.styles',
126            'ext.securepoll',
127        ] );
128
129        $election = $this->election;
130        $isRunning = $election && $election->isStarted() && !$election->isFinished();
131        $formItems = [];
132
133        $formItems['election_id'] = [
134            'type' => 'hidden',
135            'default' => -1,
136            'output-as-default' => false,
137        ];
138
139        // Submit intended to be hidden w/CSS
140        // Placed at the beginning of the form so that when the form
141        // is submitted by pressing enter while focused on an input,
142        // it will trigger this generic submit and not generate an event
143        // on a cloner add/delete item
144        $formItems['default_submit'] = [
145            'type' => 'submit',
146            'buttonlabel' => 'submit',
147            'cssclass' => 'securepoll-default-submit',
148        ];
149
150        $formItems['election_title'] = [
151            'label-message' => 'securepoll-create-label-election_title',
152            'type' => 'text',
153            'required' => true,
154            'disabled' => $isRunning,
155        ];
156
157        $wikiNames = FormStore::getWikiList();
158        $options = [];
159        $options['securepoll-create-option-wiki-this_wiki'] = WikiMap::getCurrentWikiId();
160        if ( count( $wikiNames ) > 1 ) {
161            $options['securepoll-create-option-wiki-all_wikis'] = '*';
162        }
163        $securePollCreateWikiGroupDir = $this->specialPage->getConfig()->get( 'SecurePollCreateWikiGroupDir' );
164        foreach ( $this->specialPage->getConfig()->get( 'SecurePollCreateWikiGroups' ) as $file => $msg ) {
165            if ( is_readable( "$securePollCreateWikiGroupDir$file.dblist" ) ) {
166                $options[$msg] = "@$file";
167            }
168        }
169
170        // If the only option is WikiMap::getCurrentWikiId() don't show it; otherwise...
171        if ( count( $wikiNames ) > 1 || count( $options ) > 1 ) {
172            $opts = [];
173            foreach ( $options as $msg => $value ) {
174                $opts[$this->msg( $msg )->plain()] = $value;
175            }
176            $key = array_search( WikiMap::getCurrentWikiId(), $wikiNames, true );
177            if ( $key !== false ) {
178                unset( $wikiNames[$key] );
179            }
180            if ( $wikiNames ) {
181                $opts[$this->msg( 'securepoll-create-option-wiki-other_wiki' )->plain()] = $wikiNames;
182            }
183            $formItems['property_wiki'] = [
184                'type' => 'select',
185                'options' => $opts,
186                'label-message' => 'securepoll-create-label-wiki',
187                'disabled' => $isRunning,
188            ];
189        }
190
191        $languages = $this->languageNameUtils->getLanguageNames();
192        ksort( $languages );
193        $options = [];
194        foreach ( $languages as $code => $name ) {
195            $display = LanguageCode::bcp47( $code ) . ' - ' . $name;
196            $options[$display] = $code;
197        }
198        $formItems['election_primaryLang'] = [
199            'type' => 'select',
200            'options' => $options,
201            'label-message' => 'securepoll-create-label-election_primarylang',
202            'default' => 'en',
203            'required' => true,
204            'disabled' => $isRunning,
205        ];
206
207        $formItems['election_startdate'] = [
208            'label-message' => 'securepoll-create-label-election_startdate',
209            'type' => 'datetime',
210            'required' => true,
211            'min' => $isRunning ? '' : gmdate( 'Y-m-d H:i:s' ),
212            'disabled' => $isRunning,
213        ];
214
215        $formItems['election_enddate'] = [
216            'label-message' => 'securepoll-create-label-election_enddate',
217            'type' => 'datetime',
218            'required' => true,
219            'min' => $isRunning ? '' : gmdate( 'Y-m-d H:i:s' ),
220            'validation-callback' => [
221                $this,
222                'checkElectionEndDate'
223            ],
224            'disabled' => $isRunning,
225        ];
226
227        $formItems['return-url'] = [
228            'label-message' => 'securepoll-create-label-election_return-url',
229            'type' => 'url',
230        ];
231
232        if ( isset( $formItems['property_wiki'] ) ) {
233            $formItems['jump-text'] = [
234                'label-message' => 'securepoll-create-label-election_jump-text',
235                'type' => 'text',
236                'disabled' => $isRunning,
237            ];
238            $formItems['jump-text']['hide-if'] = [
239                '===',
240                'property_wiki',
241                WikiMap::getCurrentWikiId()
242            ];
243        }
244
245        $formItems['election_type'] = [
246            'label-message' => 'securepoll-create-label-election_type',
247            'type' => 'radio',
248            'options-messages' => [],
249            'required' => true,
250            'disabled' => $isRunning,
251        ];
252
253        $cryptTypes = Crypt::getCryptTypes();
254        if ( count( $cryptTypes ) > 1 ) {
255            $formItems['election_crypt'] = [
256                'label-message' => 'securepoll-create-label-election_crypt',
257                'type' => 'radio',
258                'options-messages' => [],
259                'required' => true,
260                'disabled' => $isRunning,
261            ];
262        } else {
263            reset( $cryptTypes );
264            $formItems['election_crypt'] = [
265                'type' => 'hidden',
266                'default' => key( $cryptTypes ),
267                'options-messages' => [],
268                // dummy, ignored
269            ];
270        }
271
272        $formItems['disallow-change'] = [
273            'label-message' => 'securepoll-create-label-election_disallow-change',
274            'type' => 'check',
275            'hidelabel' => true,
276            'disabled' => $isRunning,
277        ];
278
279        $formItems['voter-privacy'] = [
280            'label-message' => 'securepoll-create-label-voter_privacy',
281            'type' => 'check',
282            'hidelabel' => true,
283            'disabled' => $isRunning,
284        ];
285
286        $formItems['property_admins'] = [
287            'label-message' => 'securepoll-create-label-property_admins',
288            'type' => 'usersmultiselect',
289            'exists' => true,
290            'required' => true,
291            'validation-callback' => [
292                $this,
293                'checkEditPollRight'
294            ],
295        ];
296
297        $formItems['request-comment'] = [
298            'label-message' => 'securepoll-create-label-request-comment',
299            'type' => 'check',
300            'disabled' => $isRunning
301        ];
302
303        $formItems['prompt-active-wiki'] = [
304            'label-message' => 'securepoll-create-label-prompt-active-wiki',
305            'type' => 'check',
306            'disabled' => $isRunning
307        ];
308
309        $formItems['comment-prompt'] = [
310            'label-message' => 'securepoll-create-label-comment-prompt',
311            'type' => 'textarea',
312            'rows' => 2,
313            'disabled' => $isRunning,
314            'hide-if' => [
315                '!==',
316                'request-comment',
317                '1'
318            ]
319        ];
320
321        $questionFields = [
322            'id' => [
323                'type' => 'hidden',
324                'default' => -1,
325                'output-as-default' => false,
326            ],
327            'text' => [
328                'label-message' => 'securepoll-create-label-questions-question',
329                'type' => 'text',
330                'validation-callback' => [
331                    $this,
332                    'checkRequired',
333                ],
334                'disabled' => $isRunning,
335            ],
336            'delete' => [
337                'type' => 'submit',
338                'default' => $this->msg( 'securepoll-create-label-questions-delete' )->text(),
339                'disabled' => $isRunning,
340                'flags' => [
341                    'destructive'
342                ],
343            ],
344        ];
345
346        $optionFields = [
347            'id' => [
348                'type' => 'hidden',
349                'default' => -1,
350                'output-as-default' => false,
351            ],
352            'text' => [
353                'label-message' => 'securepoll-create-label-options-option',
354                'type' => 'text',
355                'validation-callback' => [
356                    $this,
357                    'checkRequired',
358                ],
359                'disabled' => $isRunning,
360            ],
361            'delete' => [
362                'type' => 'submit',
363                'default' => $this->msg( 'securepoll-create-label-options-delete' )->text(),
364                'disabled' => $isRunning,
365                'flags' => [
366                    'destructive'
367                ],
368            ],
369        ];
370
371        $tallyTypes = [];
372        foreach ( $this->context->getBallotTypesForVote() as $ballotType => $ballotClass ) {
373            $types = [];
374            foreach ( $ballotClass::getTallyTypes() as $tallyType ) {
375                $type = "$ballotType+$tallyType";
376                $types[] = $type;
377                $tallyTypes[$tallyType][] = $type;
378                $formItems['election_type']['options-messages']["securepoll-create-option-election_type-$type"]
379                    = $type;
380            }
381
382            self::processFormItems(
383                $formItems,
384                'election_type',
385                $types,
386                $ballotClass,
387                'election',
388                $isRunning
389            );
390            self::processFormItems(
391                $questionFields,
392                'election_type',
393                $types,
394                $ballotClass,
395                'question',
396                $isRunning
397            );
398            self::processFormItems(
399                $optionFields,
400                'election_type',
401                $types,
402                $ballotClass,
403                'option',
404                $isRunning
405            );
406        }
407
408        foreach ( Tallier::$tallierTypes as $type => $class ) {
409            if ( !isset( $tallyTypes[$type] ) ) {
410                continue;
411            }
412            self::processFormItems(
413                $formItems,
414                'election_type',
415                $tallyTypes[$type],
416                $class,
417                'election',
418                $isRunning
419            );
420            self::processFormItems(
421                $questionFields,
422                'election_type',
423                $tallyTypes[$type],
424                $class,
425                'question',
426                $isRunning
427            );
428            self::processFormItems(
429                $optionFields,
430                'election_type',
431                $tallyTypes[$type],
432                $class,
433                'option',
434                $isRunning
435            );
436        }
437
438        foreach ( Crypt::getCryptTypes() as $type => $class ) {
439            $formItems['election_crypt']['options-messages']["securepoll-create-option-election_crypt-$type"]
440                = $type;
441            if ( $class !== false ) {
442                self::processFormItems(
443                    $formItems,
444                    'election_crypt',
445                    $type,
446                    $class,
447                    'election',
448                    $isRunning
449                );
450                self::processFormItems(
451                    $questionFields,
452                    'election_crypt',
453                    $type,
454                    $class,
455                    'question',
456                    $isRunning
457                );
458                self::processFormItems(
459                    $optionFields,
460                    'election_crypt',
461                    $type,
462                    $class,
463                    'option',
464                    $isRunning
465                );
466            }
467        }
468
469        $questionFields['options'] = [
470            'label-message' => 'securepoll-create-label-questions-option',
471            'type' => 'cloner',
472            'required' => true,
473            'create-button-message' => 'securepoll-create-label-options-add',
474            'fields' => $optionFields,
475            'disabled' => $isRunning,
476        ];
477
478        $formItems['questions'] = [
479            'label-message' => 'securepoll-create-label-questions',
480            'type' => 'cloner',
481            'row-legend' => 'securepoll-create-questions-row-legend',
482            'create-button-message' => 'securepoll-create-label-questions-add',
483            'fields' => $questionFields,
484            'disabled' => $isRunning,
485        ];
486
487        if ( $this->specialPage->getConfig()->get( 'SecurePollUseNamespace' ) ) {
488            $formItems['comment'] = [
489                'type' => 'text',
490                'label-message' => 'securepoll-create-label-comment',
491                'maxlength' => 250,
492            ];
493        }
494
495        // Set form field defaults from any existing election
496        if ( $this->election ) {
497            $existingFieldData = $this->getFormDataFromElection();
498            foreach ( $existingFieldData as $fieldName => $fieldValue ) {
499                if ( isset( $formItems[ $fieldName ] ) ) {
500                    $formItems[ $fieldName ]['default'] = $fieldValue;
501                }
502            }
503        }
504
505        $form = HTMLForm::factory(
506            'ooui',
507            $formItems,
508            $this->specialPage->getContext(),
509            $this->election ? 'securepoll-edit' : 'securepoll-create'
510        );
511
512        $form->setSubmitTextMsg(
513            $this->election ? 'securepoll-edit-action' : 'securepoll-create-action'
514        );
515        $form->setSubmitCallback(
516            [
517                $this,
518                $isRunning ? 'processInputDuringElection' : 'processInput'
519            ]
520        );
521        $form->prepareForm();
522
523        // If this isn't the result of a POST, load the data from the election
524        $request = $this->specialPage->getRequest();
525        if ( $this->election && !( $request->wasPosted() && $request->getCheck(
526                    'wpEditToken'
527                ) )
528        ) {
529            $form->mFieldData = $this->getFormDataFromElection();
530        }
531
532        $result = $form->tryAuthorizedSubmit();
533        if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
534            if ( $this->election ) {
535                $out->setPageTitleMsg( $this->msg( 'securepoll-edit-edited' ) );
536                $out->addWikiMsg( 'securepoll-edit-edited-text' );
537            } else {
538                $out->setPageTitleMsg( $this->msg( 'securepoll-create-created' ) );
539                $out->addWikiMsg( 'securepoll-create-created-text' );
540            }
541            $out->returnToMain( false, SpecialPage::getTitleFor( 'SecurePoll' ) );
542        } else {
543            $form->displayForm( $result );
544        }
545    }
546
547    public function processInputDuringElection( $formData ) {
548        // If editing a poll while it's running, only allow certain fields to be updated
549        // For now only property_admins and return-url can be edited
550        $fields = [
551            'admins' => implode( '|', explode( "\n", $formData['property_admins'] ) ),
552            'return-url' => $formData['return-url']
553        ];
554
555        $originalFormData = [];
556        $securePollUseLogging = $this->specialPage->getConfig()->get( 'SecurePollUseLogging' );
557        if ( $securePollUseLogging ) {
558            // Store original form data for logging
559            $originalFormData = $this->getFormDataFromElection();
560        }
561
562        $dbw = $this->lbFactory->getMainLB()->getConnection( ILoadBalancer::DB_PRIMARY );
563        $dbw->startAtomic( __METHOD__ );
564        foreach ( $fields as $pr_key => $pr_value ) {
565            $dbw->newUpdateQueryBuilder()
566                ->update( 'securepoll_properties' )
567                ->set( [ 'pr_value' => $pr_value ] )
568                ->where( [
569                    'pr_entity' => $this->election->getId(),
570                    'pr_key' => $pr_key
571                ] )
572                ->caller( __METHOD__ )
573                ->execute();
574        }
575        $dbw->endAtomic( __METHOD__ );
576
577        // Log any changes to admins
578        if ( $securePollUseLogging ) {
579            $this->logAdminChanges( $originalFormData, $formData, $this->election->getId() );
580        }
581
582        $this->recordElectionToNamespace( $this->election->getId(), $formData );
583
584        return Status::newGood( $this->election->getId() );
585    }
586
587    public function processInput( $formData, $form ) {
588        try {
589            $context = new Context;
590            $userId = $this->specialPage->getUser()->getId();
591            $store = new FormStore;
592            $context->setStore( $store );
593            $store->setFormData( $context, $formData, $userId );
594            $election = $context->getElection( $store->eId );
595
596            if ( $this->election && $store->eId !== (int)$this->election->getId() ) {
597                return Status::newFatal( 'securepoll-create-fail-bad-id' );
598            }
599
600            // Get a connection in autocommit mode so that it is possible to do
601            // explicit transactions on it (T287859)
602            $dbw = $this->lbFactory->getMainLB()->getConnection( ILoadBalancer::DB_PRIMARY,
603                [], false, ILoadBalancer::CONN_TRX_AUTOCOMMIT );
604
605            // Check for duplicate titles on the local wiki
606            $id = $dbw->newSelectQueryBuilder()
607                ->select( 'el_entity' )
608                ->from( 'securepoll_elections' )
609                ->where( [ 'el_title' => $election->title ] )
610                ->caller( __METHOD__ )
611                ->fetchField();
612            if ( $id && (int)$id !== $election->getId() ) {
613                throw new StatusException(
614                    'securepoll-create-duplicate-title',
615                    FormStore::getWikiName( WikiMap::getCurrentWikiId() ),
616                    WikiMap::getCurrentWikiId()
617                );
618            }
619
620            // Check for duplicate titles on jump wikis too
621            // (There's the possibility for a race here, but hopefully it won't
622            // matter in practice)
623            if ( $store->rId ) {
624                foreach ( $store->remoteWikis as $dbname ) {
625                    $lb = $this->lbFactory->getMainLB( $dbname );
626                    // Use autocommit mode so that we can share connections with
627                    // the write code below
628                    $rdbw = $lb->getConnection( DB_PRIMARY, [], $dbname,
629                        ILoadBalancer::CONN_TRX_AUTOCOMMIT );
630
631                    // Find an existing dummy election, if any
632                    $rId = $rdbw->newSelectQueryBuilder()
633                        ->select( 'p1.pr_entity' )
634                        ->from( 'securepoll_properties', 'p1' )
635                        ->join( 'securepoll_properties', 'p2', 'p1.pr_entity = p2.pr_entity' )
636                        ->where( [
637                            'p1.pr_key' => 'jump-id',
638                            'p1.pr_value' => $election->getId(),
639                            'p2.pr_key' => 'main-wiki',
640                            'p2.pr_value' => WikiMap::getCurrentWikiId(),
641                        ] )
642                        ->caller( __METHOD__ )
643                        ->fetchField();
644                    // Test for duplicate title
645                    $id = $rdbw->newSelectQueryBuilder()
646                        ->select( 'el_entity' )
647                        ->from( 'securepoll_elections' )
648                        ->where( [
649                            'el_title' => $formData['election_title']
650                        ] )
651                        ->caller( __METHOD__ )
652                        ->fetchField();
653
654                    if ( $id && $id !== $rId ) {
655                        throw new StatusException(
656                            'securepoll-create-duplicate-title',
657                            FormStore::getWikiName( $dbname ),
658                            $dbname
659                        );
660                    }
661                }
662            }
663        } catch ( StatusException $ex ) {
664            return $ex->status;
665        }
666
667        $originalFormData = [];
668        $securePollUseLogging = $this->specialPage->getConfig()->get( 'SecurePollUseLogging' );
669        if ( $securePollUseLogging && $this->election ) {
670            // Store original form data for logging
671            $originalFormData = $this->getFormDataFromElection();
672        }
673
674        // Ok, begin the actual work
675        $dbw->startAtomic( __METHOD__ );
676        if ( $election->getId() > 0 ) {
677            $id = $dbw->newSelectQueryBuilder()
678                ->select( 'el_entity' )
679                ->from( 'securepoll_elections' )
680                ->where( [
681                    'el_entity' => $election->getId()
682                ] )
683                ->forUpdate()
684                ->caller( __METHOD__ )
685                ->fetchField();
686            if ( !$id ) {
687                $dbw->endAtomic( __METHOD__ );
688
689                return Status::newFatal( 'securepoll-create-fail-id-missing' );
690            }
691        }
692
693        // Insert or update the election entity
694        $fields = [
695            'el_title' => $election->title,
696            'el_ballot' => $election->ballotType,
697            'el_tally' => $election->tallyType,
698            'el_primary_lang' => $election->getLanguage(),
699            'el_start_date' => $dbw->timestamp( $election->getStartDate() ),
700            'el_end_date' => $dbw->timestamp( $election->getEndDate() ),
701            'el_auth_type' => $election->authType,
702            'el_owner' => $election->owner,
703        ];
704        if ( $election->getId() < 0 ) {
705            $eId = self::insertEntity( $dbw, 'election' );
706            $qIds = [];
707            $oIds = [];
708            $fields['el_entity'] = $eId;
709            $dbw->newInsertQueryBuilder()
710                ->insertInto( 'securepoll_elections' )
711                ->row( $fields )
712                ->caller( __METHOD__ )
713                ->execute();
714
715            // Enable sitewide block by default on new elections
716            $dbw->newInsertQueryBuilder()
717                ->insertInto( 'securepoll_properties' )
718                ->row( [
719                    'pr_entity' => $eId,
720                    'pr_key' => 'not-sitewide-blocked',
721                    'pr_value' => 1,
722                ] )
723                ->caller( __METHOD__ )
724                ->execute();
725        } else {
726            $eId = $election->getId();
727            $dbw->newUpdateQueryBuilder()
728                ->update( 'securepoll_elections' )
729                ->set( $fields )
730                ->where( [ 'el_entity' => $eId ] )
731                ->caller( __METHOD__ )
732                ->execute();
733
734            // Delete any questions or options that weren't included in the
735            // form submission.
736            $qIds = $dbw->newSelectQueryBuilder()
737                ->select( 'qu_entity' )
738                ->from( 'securepoll_questions' )
739                ->where( [ 'qu_election' => $eId ] )
740                ->caller( __METHOD__ )
741                ->fetchFieldValues();
742            $oIds = $dbw->newSelectQueryBuilder()
743                ->select( 'op_entity' )
744                ->from( 'securepoll_options' )
745                ->where( [ 'op_election' => $eId ] )
746                ->caller( __METHOD__ )
747                ->fetchFieldValues();
748            $deleteIds = array_merge(
749                array_diff( $qIds, $store->qIds ),
750                array_diff( $oIds, $store->oIds )
751            );
752            if ( $deleteIds ) {
753                $dbw->newDeleteQueryBuilder()
754                    ->deleteFrom( 'securepoll_msgs' )
755                    ->where( [ 'msg_entity' => $deleteIds ] )
756                    ->caller( __METHOD__ )
757                    ->execute();
758                $dbw->newDeleteQueryBuilder()
759                    ->deleteFrom( 'securepoll_properties' )
760                    ->where( [ 'pr_entity' => $deleteIds ] )
761                    ->caller( __METHOD__ )
762                    ->execute();
763                $dbw->newDeleteQueryBuilder()
764                    ->deleteFrom( 'securepoll_questions' )
765                    ->where( [ 'qu_entity' => $deleteIds ] )
766                    ->caller( __METHOD__ )
767                    ->execute();
768                $dbw->newDeleteQueryBuilder()
769                    ->deleteFrom( 'securepoll_options' )
770                    ->where( [ 'op_entity' => $deleteIds ] )
771                    ->caller( __METHOD__ )
772                    ->execute();
773                $dbw->newDeleteQueryBuilder()
774                    ->deleteFrom( 'securepoll_entity' )
775                    ->where( [ 'en_id' => $deleteIds ] )
776                    ->caller( __METHOD__ )
777                    ->execute();
778            }
779        }
780        self::savePropertiesAndMessages( $dbw, $eId, $election );
781
782        // Now do questions and options
783        $qIndex = 0;
784        foreach ( $election->getQuestions() as $question ) {
785            $qId = $question->getId();
786            if ( !in_array( $qId, $qIds ) ) {
787                $qId = self::insertEntity( $dbw, 'question' );
788            }
789            $dbw->newReplaceQueryBuilder()
790                ->replaceInto( 'securepoll_questions' )
791                ->uniqueIndexFields( 'qu_entity' )
792                ->row( [
793                    'qu_entity' => $qId,
794                    'qu_election' => $eId,
795                    'qu_index' => ++$qIndex,
796                ] )
797                ->caller( __METHOD__ )
798                ->execute();
799            self::savePropertiesAndMessages( $dbw, $qId, $question );
800
801            foreach ( $question->getOptions() as $option ) {
802                $oId = $option->getId();
803                if ( !in_array( $oId, $oIds ) ) {
804                    $oId = self::insertEntity( $dbw, 'option' );
805                }
806                $dbw->newReplaceQueryBuilder()
807                    ->replaceInto( 'securepoll_options' )
808                    ->uniqueIndexFields( 'op_entity' )
809                    ->row( [
810                        'op_entity' => $oId,
811                        'op_election' => $eId,
812                        'op_question' => $qId,
813                    ] )
814                    ->caller( __METHOD__ )
815                    ->execute();
816                self::savePropertiesAndMessages( $dbw, $oId, $option );
817            }
818        }
819        $dbw->endAtomic( __METHOD__ );
820
821        if ( $securePollUseLogging ) {
822            $this->logAdminChanges( $originalFormData, $formData, $eId );
823        }
824
825        // Create the "redirect" polls on foreign wikis
826        if ( $store->rId ) {
827            $election = $context->getElection( $store->rId );
828            foreach ( $store->remoteWikis as $dbname ) {
829                $lb = $this->lbFactory->getMainLB( $dbname );
830                // As for the local wiki, request autocommit mode to get outer transaction scope
831                $dbw = $lb->getConnection( ILoadBalancer::DB_PRIMARY, [], $dbname,
832                    ILoadBalancer::CONN_TRX_AUTOCOMMIT );
833                $dbw->startAtomic( __METHOD__ );
834                // Find an existing dummy election, if any
835                $rId = $dbw->newSelectQueryBuilder()
836                    ->select( 'p1.pr_entity' )
837                    ->from( 'securepoll_properties', 'p1' )
838                    ->join( 'securepoll_properties', 'p2', 'p1.pr_entity = p2.pr_entity' )
839                    ->where( [
840                        'p1.pr_key' => 'jump-id',
841                        'p1.pr_value' => $eId,
842                        'p2.pr_key' => 'main-wiki',
843                        'p2.pr_value' => WikiMap::getCurrentWikiId(),
844                    ] )
845                    ->caller( __METHOD__ )
846                    ->fetchField();
847                if ( !$rId ) {
848                    $rId = self::insertEntity( $dbw, 'election' );
849                }
850
851                // Insert it! We don't have to care about questions or options here.
852                $dbw->newReplaceQueryBuilder()
853                    ->replaceInto( 'securepoll_elections' )
854                    ->uniqueIndexFields( 'el_entity' )
855                    ->row( [
856                        'el_entity' => $rId,
857                        'el_title' => $election->title,
858                        'el_ballot' => $election->ballotType,
859                        'el_tally' => $election->tallyType,
860                        'el_primary_lang' => $election->getLanguage(),
861                        'el_start_date' => $dbw->timestamp( $election->getStartDate() ),
862                        'el_end_date' => $dbw->timestamp( $election->getEndDate() ),
863                        'el_auth_type' => $election->authType,
864                        'el_owner' => $election->owner,
865                    ] )
866                    ->caller( __METHOD__ )
867                    ->execute();
868                self::savePropertiesAndMessages( $dbw, $rId, $election );
869
870                // Fix jump-id
871                $dbw->newUpdateQueryBuilder()
872                    ->update( 'securepoll_properties' )
873                    ->set( [ 'pr_value' => $eId ] )
874                    ->where( [
875                        'pr_entity' => $rId,
876                        'pr_key' => 'jump-id'
877                    ] )
878                    ->caller( __METHOD__ )
879                    ->execute();
880                $dbw->endAtomic( __METHOD__ );
881            }
882        }
883
884        $this->recordElectionToNamespace( $eId, $formData );
885
886        return Status::newGood( $eId );
887    }
888
889    /**
890     * Record this election to the SecurePoll namespace, if so configured.
891     *
892     * @param int $eId election id
893     * @param array $formData
894     */
895    private function recordElectionToNamespace( $eId, $formData ) {
896        if ( $this->specialPage->getConfig()->get( 'SecurePollUseNamespace' ) ) {
897            // Create a new context to bypass caching.
898            $context = new Context;
899            // We may be inside a transaction, so force a primary DB connection (T209804)
900            $context->getStore()->setForcePrimary( true );
901
902            $election = $context->getElection( $eId );
903
904            [ $title, $content ] = SecurePollContentHandler::makeContentFromElection(
905                $election
906            );
907            $wp = $this->wikiPageFactory->newFromTitle( $title );
908            $wp->doUserEditContent(
909                $content,
910                $this->specialPage->getUser(),
911                $formData['comment']
912            );
913
914            [ $title, $content ] = SecurePollContentHandler::makeContentFromElection(
915                $election,
916                'msg/' . $election->getLanguage()
917            );
918            $wp = $this->wikiPageFactory->newFromTitle( $title );
919            $wp->doUserEditContent(
920                $content,
921                $this->specialPage->getUser(),
922                $formData['comment']
923            );
924        }
925    }
926
927    /**
928     * Log changes made to the admins of the election.
929     *
930     * @param array $originalFormData Empty array if no election exists
931     * @param array $formData
932     * @param int $electionId
933     */
934    private function logAdminChanges(
935        array $originalFormData,
936        array $formData,
937        int $electionId
938    ): void {
939        if ( isset( $originalFormData['property_admins'] ) ) {
940            $oldAdmins = explode( "\n", $originalFormData['property_admins'] );
941        } else {
942            $oldAdmins = [];
943        }
944        $newAdmins = explode( "\n", $formData['property_admins'] );
945
946        if ( $oldAdmins === $newAdmins ) {
947            return;
948        }
949
950        $actions = [
951            self::LOG_TYPE_ADDADMIN => array_diff( $newAdmins, $oldAdmins ),
952            self::LOG_TYPE_REMOVEADMIN => array_diff( $oldAdmins, $newAdmins ),
953        ];
954
955        $dbw = $this->lbFactory->getMainLB()->getConnection( ILoadBalancer::DB_PRIMARY );
956        $fields = [
957            'spl_timestamp' => $dbw->timestamp( time() ),
958            'spl_election_id' => $electionId,
959            'spl_user' => $this->specialPage->getUser()->getId(),
960        ];
961
962        foreach ( array_keys( $actions ) as $action ) {
963            foreach ( $actions[$action] as $admin ) {
964                $dbw->newInsertQueryBuilder()
965                    ->insertInto( 'securepoll_log' )
966                    ->row( $fields + [
967                        'spl_type' => $action,
968                        'spl_target' => $this->userFactory->newFromName( $admin )->getId(),
969                    ] )
970                    ->caller( __METHOD__ )
971                    ->execute();
972            }
973        }
974    }
975
976    /**
977     * Recreate the form data from an election
978     *
979     * @return array
980     */
981    private function getFormDataFromElection() {
982        $lang = $this->election->getLanguage();
983        $data = array_replace_recursive(
984            SecurePollContentHandler::getDataFromElection( $this->election, "msg/$lang" ),
985            SecurePollContentHandler::getDataFromElection( $this->election )
986        );
987        $p = &$data['properties'];
988        $m = &$data['messages'];
989
990        $startDate = new MWTimestamp( $data['startDate'] );
991        $endDate = new MWTimestamp( $data['endDate'] );
992
993        $ballot = $data['ballot'];
994        $tally = $data['tally'];
995        $crypt = $p['encrypt-type'] ?? 'none';
996
997        $formData = [
998            'election_id' => $data['id'],
999            'election_title' => $data['title'],
1000            'property_wiki' => $p['wikis-val'] ?? null,
1001            'election_primaryLang' => $data['lang'],
1002            'election_startdate' => $startDate->format( 'Y-m-d\TH:i:s.0\Z' ),
1003            'election_enddate' => $endDate->format( 'Y-m-d\TH:i:s.0\Z' ),
1004            'return-url' => $p['return-url'] ?? null,
1005            'jump-text' => $m['jump-text'] ?? null,
1006            'election_type' => "{$ballot}+{$tally}",
1007            'election_crypt' => $crypt,
1008            'disallow-change' => isset( $p['disallow-change'] ) ? (bool)$p['disallow-change'] : null,
1009            'voter-privacy' => isset( $p['voter-privacy'] ) ? (bool)$p['voter-privacy'] : null,
1010            'property_admins' => '',
1011            'request-comment' => isset( $p['request-comment'] ) ? (bool)$p['request-comment'] : null,
1012            'prompt-active-wiki' => isset( $p['prompt-active-wiki'] ) ? (bool)$p['prompt-active-wiki'] : null,
1013            'comment-prompt' => $m['comment-prompt'] ?? null,
1014            'questions' => [],
1015            'comment' => '',
1016        ];
1017
1018        if ( isset( $data['properties']['admins'] ) ) {
1019            // HTMLUsersMultiselectField takes a line-separated string
1020            $formData['property_admins'] = implode( "\n", explode( '|', $data['properties']['admins'] ) );
1021        }
1022
1023        $classes = [];
1024        $tallyTypes = [];
1025        foreach ( $this->context->getBallotTypesForVote() as $class ) {
1026            $classes[] = $class;
1027            foreach ( $class::getTallyTypes() as $type ) {
1028                $tallyTypes[$type] = true;
1029            }
1030        }
1031        foreach ( Tallier::$tallierTypes as $type => $class ) {
1032            if ( isset( $tallyTypes[$type] ) ) {
1033                $classes[] = $class;
1034            }
1035        }
1036        foreach ( Crypt::getCryptTypes() as $class ) {
1037            if ( $class !== false ) {
1038                $classes[] = $class;
1039            }
1040        }
1041
1042        foreach ( $classes as $class ) {
1043            self::unprocessFormData( $formData, $data, $class, 'election' );
1044        }
1045
1046        foreach ( $data['questions'] as $question ) {
1047            $q = [
1048                'text' => $question['messages']['text'],
1049            ];
1050            if ( isset( $question['id'] ) ) {
1051                $q['id'] = $question['id'];
1052            }
1053
1054            foreach ( $classes as $class ) {
1055                self::unprocessFormData( $q, $question, $class, 'question' );
1056            }
1057
1058            // Process options for this question
1059            foreach ( $question['options'] as $option ) {
1060                $o = [
1061                    'text' => $option['messages']['text'],
1062                ];
1063                if ( isset( $option['id'] ) ) {
1064                    $o['id'] = $option['id'];
1065                }
1066
1067                foreach ( $classes as $class ) {
1068                    self::unprocessFormData( $o, $option, $class, 'option' );
1069                }
1070
1071                $q['options'][] = $o;
1072            }
1073
1074            $formData['questions'][] = $q;
1075        }
1076
1077        return $formData;
1078    }
1079
1080    /**
1081     * Insert an entry into the securepoll_entities table, and return the ID
1082     *
1083     * @param IDatabase $dbw
1084     * @param string $type Entity type
1085     * @return int
1086     */
1087    private static function insertEntity( $dbw, $type ) {
1088        $dbw->newInsertQueryBuilder()
1089            ->insertInto( 'securepoll_entity' )
1090            ->row( [
1091                'en_type' => $type,
1092            ] )
1093            ->caller( __METHOD__ )
1094            ->execute();
1095
1096        return $dbw->insertId();
1097    }
1098
1099    /**
1100     * Save properties and messages for an entity
1101     *
1102     * @param IDatabase $dbw
1103     * @param int $id
1104     * @param Entity $entity
1105     */
1106    private static function savePropertiesAndMessages( $dbw, $id, $entity ) {
1107        $properties = [];
1108        foreach ( $entity->getAllProperties() as $key => $value ) {
1109            $properties[] = [
1110                'pr_entity' => $id,
1111                'pr_key' => $key,
1112                'pr_value' => $value,
1113            ];
1114        }
1115        if ( $properties ) {
1116            $dbw->newReplaceQueryBuilder()
1117                ->replaceInto( 'securepoll_properties' )
1118                ->uniqueIndexFields( [ 'pr_entity', 'pr_key' ] )
1119                ->rows( $properties )
1120                ->caller( __METHOD__ )
1121                ->execute();
1122        }
1123
1124        $messages = [];
1125        $langs = $entity->getLangList();
1126        foreach ( $entity->getMessageNames() as $name ) {
1127            foreach ( $langs as $lang ) {
1128                $value = $entity->getRawMessage( $name, $lang );
1129                if ( $value !== false ) {
1130                    $messages[] = [
1131                        'msg_entity' => $id,
1132                        'msg_lang' => $lang,
1133                        'msg_key' => $name,
1134                        'msg_text' => $value,
1135                    ];
1136                }
1137            }
1138        }
1139        if ( $messages ) {
1140            $dbw->newReplaceQueryBuilder()
1141                ->replaceInto( 'securepoll_msgs' )
1142                ->uniqueIndexFields( [ 'msg_entity', 'msg_lang', 'msg_key' ] )
1143                ->rows( $messages )
1144                ->caller( __METHOD__ )
1145                ->execute();
1146        }
1147    }
1148
1149    /**
1150     * Combine form items for the class into the main array
1151     *
1152     * @param array &$outItems Array to insert the descriptors into
1153     * @param string $field Owning field name, for hide-if
1154     * @param string|array $types Type value(s) in the field, for hide-if
1155     * @param class-string<Ballot|Crypt|Tallier>|false $class
1156     * @param string|null $category If given, ::getCreateDescriptors is
1157     *    expected to return an array with subarrays for different categories
1158     *    of descriptors, and this selects which subarray to process.
1159     * @param bool|null $disabled Should the field be disabled
1160     */
1161    private static function processFormItems(
1162        &$outItems, $field, $types, $class,
1163        $category = null,
1164        $disabled = false
1165    ) {
1166        if ( $class === false ) {
1167            return;
1168        }
1169
1170        $items = $class::getCreateDescriptors();
1171
1172        if ( !is_array( $types ) ) {
1173            $types = [ $types ];
1174        }
1175
1176        if ( $category ) {
1177            if ( !isset( $items[$category] ) ) {
1178                return;
1179            }
1180            $items = $items[$category];
1181        }
1182
1183        foreach ( $items as $key => $item ) {
1184            if ( $disabled ) {
1185                $item['disabled'] = true;
1186            }
1187            if ( !isset( $outItems[$key] ) ) {
1188                if ( !isset( $item['hide-if'] ) ) {
1189                    $item['hide-if'] = [
1190                        'OR',
1191                        [ 'AND' ]
1192                    ];
1193                } else {
1194                    $item['hide-if'] = [
1195                        'OR',
1196                        [ 'AND' ],
1197                        $item['hide-if']
1198                    ];
1199                }
1200                $outItems[$key] = $item;
1201            } else {
1202                // @todo Detect if this is really the same descriptor?
1203            }
1204            foreach ( $types as $type ) {
1205                $outItems[$key]['hide-if'][1][] = [
1206                    '!==',
1207                    $field,
1208                    $type
1209                ];
1210            }
1211        }
1212    }
1213
1214    /**
1215     * Inject form field values for the class's properties and messages
1216     *
1217     * @param array &$formData Form data array
1218     * @param array $data Input data array
1219     * @param class-string<Ballot|Crypt|Tallier>|false $class
1220     * @param string|null $category If given, ::getCreateDescriptors is
1221     *    expected to return an array with subarrays for different categories
1222     *    of descriptors, and this selects which subarray to process.
1223     */
1224    private static function unprocessFormData( &$formData, $data, $class, $category ) {
1225        if ( $class === false ) {
1226            return;
1227        }
1228
1229        $items = $class::getCreateDescriptors();
1230
1231        if ( $category ) {
1232            if ( !isset( $items[$category] ) ) {
1233                return;
1234            }
1235            $items = $items[$category];
1236        }
1237
1238        foreach ( $items as $key => $item ) {
1239            if ( !isset( $item['SecurePoll_type'] ) ) {
1240                continue;
1241            }
1242            switch ( $item['SecurePoll_type'] ) {
1243                case 'property':
1244                    if ( isset( $data['properties'][$key] ) ) {
1245                        $formData[$key] = $data['properties'][$key];
1246                    } else {
1247                        $formData[$key] = null;
1248                    }
1249                    break;
1250                case 'properties':
1251                    $formData[$key] = [];
1252                    foreach ( $data['properties'] as $k => $v ) {
1253                        $formData[$key][$k] = $v;
1254                    }
1255                    break;
1256                case 'message':
1257                    if ( isset( $data['messages'][$key] ) ) {
1258                        $formData[$key] = $data['messages'][$key];
1259                    } else {
1260                        $formData[$key] = null;
1261                    }
1262                    break;
1263                case 'messages':
1264                    $formData[$key] = [];
1265                    foreach ( $data['messages'] as $k => $v ) {
1266                        $formData[$key][$k] = $v;
1267                    }
1268                    break;
1269            }
1270        }
1271    }
1272
1273    /**
1274     * Check that the user has the securepoll-edit-poll right
1275     *
1276     * @param string $value Username
1277     * @param array $alldata All form data
1278     * @param HTMLForm $containingForm Containing HTMLForm
1279     * @return bool|string true on success, string on error
1280     */
1281    public function checkEditPollRight( $value, $alldata, HTMLForm $containingForm ) {
1282        $user = $this->userFactory->newFromName( $value );
1283        if ( !$user || !$user->isAllowed( 'securepoll-edit-poll' ) ) {
1284            return $this->msg(
1285                'securepoll-create-user-missing-edit-right',
1286                $value
1287            )->parse();
1288        }
1289
1290        return true;
1291    }
1292
1293    public function checkElectionEndDate( $value, $formData ) {
1294        $startDate = new DateTime( $formData['election_startdate'], new DateTimeZone( 'GMT' ) );
1295        $endDate = new DateTime( $value, new DateTimeZone( 'GMT' ) );
1296
1297        if ( $startDate >= $endDate ) {
1298            return $this->msg( 'securepoll-htmlform-daterange-end-before-start' )->parseAsBlock();
1299        }
1300
1301        return true;
1302    }
1303
1304    /**
1305     * Check that a required field has been filled.
1306     *
1307     * This is a hack for using with cloner fields. Just setting required=true
1308     * breaks cloner fields when used with OOUI, in no-JS environments, because
1309     * the browser will prevent submission on clicking the remove button of an
1310     * empty field.
1311     *
1312     * @internal For use by the HTMLFormField
1313     * @param string $value
1314     * @return true|Message true on success, Message on error
1315     */
1316    public static function checkRequired( $value ) {
1317        if ( $value === '' ) {
1318            return Status::newFatal( 'htmlform-required' )->getMessage();
1319        }
1320        return true;
1321    }
1322}