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