Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 884
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 / 884
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 / 390
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 / 231
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            'default' => '',
292            'validation-callback' => [
293                $this,
294                'checkEditPollRight'
295            ],
296        ];
297
298        $formItems['request-comment'] = [
299            'label-message' => 'securepoll-create-label-request-comment',
300            'type' => 'check',
301            'disabled' => $isRunning
302        ];
303
304        $formItems['prompt-active-wiki'] = [
305            'label-message' => 'securepoll-create-label-prompt-active-wiki',
306            'type' => 'check',
307            'disabled' => $isRunning
308        ];
309
310        $formItems['comment-prompt'] = [
311            'label-message' => 'securepoll-create-label-comment-prompt',
312            'type' => 'textarea',
313            'rows' => 2,
314            'disabled' => $isRunning,
315            'hide-if' => [
316                '!==',
317                'request-comment',
318                '1'
319            ]
320        ];
321
322        $questionFields = [
323            'id' => [
324                'type' => 'hidden',
325                'default' => -1,
326                'output-as-default' => false,
327            ],
328            'text' => [
329                'label-message' => 'securepoll-create-label-questions-question',
330                'type' => 'text',
331                'validation-callback' => [
332                    $this,
333                    'checkRequired',
334                ],
335                'disabled' => $isRunning,
336            ],
337            'delete' => [
338                'type' => 'submit',
339                'default' => $this->msg( 'securepoll-create-label-questions-delete' )->text(),
340                'disabled' => $isRunning,
341                'flags' => [
342                    'destructive'
343                ],
344            ],
345        ];
346
347        $optionFields = [
348            'id' => [
349                'type' => 'hidden',
350                'default' => -1,
351                'output-as-default' => false,
352            ],
353            'text' => [
354                'label-message' => 'securepoll-create-label-options-option',
355                'type' => 'text',
356                'validation-callback' => [
357                    $this,
358                    'checkRequired',
359                ],
360                'disabled' => $isRunning,
361            ],
362            'delete' => [
363                'type' => 'submit',
364                'default' => $this->msg( 'securepoll-create-label-options-delete' )->text(),
365                'disabled' => $isRunning,
366                'flags' => [
367                    'destructive'
368                ],
369            ],
370        ];
371
372        $tallyTypes = [];
373        foreach ( $this->context->getBallotTypesForVote() as $ballotType => $ballotClass ) {
374            $types = [];
375            foreach ( $ballotClass::getTallyTypes() as $tallyType ) {
376                $type = "$ballotType+$tallyType";
377                $types[] = $type;
378                $tallyTypes[$tallyType][] = $type;
379                $formItems['election_type']['options-messages']["securepoll-create-option-election_type-$type"]
380                    = $type;
381            }
382
383            self::processFormItems(
384                $formItems,
385                'election_type',
386                $types,
387                $ballotClass,
388                'election',
389                $isRunning
390            );
391            self::processFormItems(
392                $questionFields,
393                'election_type',
394                $types,
395                $ballotClass,
396                'question',
397                $isRunning
398            );
399            self::processFormItems(
400                $optionFields,
401                'election_type',
402                $types,
403                $ballotClass,
404                'option',
405                $isRunning
406            );
407        }
408
409        foreach ( Tallier::$tallierTypes as $type => $class ) {
410            if ( !isset( $tallyTypes[$type] ) ) {
411                continue;
412            }
413            self::processFormItems(
414                $formItems,
415                'election_type',
416                $tallyTypes[$type],
417                $class,
418                'election',
419                $isRunning
420            );
421            self::processFormItems(
422                $questionFields,
423                'election_type',
424                $tallyTypes[$type],
425                $class,
426                'question',
427                $isRunning
428            );
429            self::processFormItems(
430                $optionFields,
431                'election_type',
432                $tallyTypes[$type],
433                $class,
434                'option',
435                $isRunning
436            );
437        }
438
439        foreach ( Crypt::getCryptTypes() as $type => $class ) {
440            $formItems['election_crypt']['options-messages']["securepoll-create-option-election_crypt-$type"]
441                = $type;
442            if ( $class !== false ) {
443                self::processFormItems(
444                    $formItems,
445                    'election_crypt',
446                    $type,
447                    $class,
448                    'election',
449                    $isRunning
450                );
451                self::processFormItems(
452                    $questionFields,
453                    'election_crypt',
454                    $type,
455                    $class,
456                    'question',
457                    $isRunning
458                );
459                self::processFormItems(
460                    $optionFields,
461                    'election_crypt',
462                    $type,
463                    $class,
464                    'option',
465                    $isRunning
466                );
467            }
468        }
469
470        $questionFields['options'] = [
471            'label-message' => 'securepoll-create-label-questions-option',
472            'type' => 'cloner',
473            'required' => true,
474            'create-button-message' => 'securepoll-create-label-options-add',
475            'fields' => $optionFields,
476            'disabled' => $isRunning,
477        ];
478
479        $formItems['questions'] = [
480            'label-message' => 'securepoll-create-label-questions',
481            'type' => 'cloner',
482            'row-legend' => 'securepoll-create-questions-row-legend',
483            'create-button-message' => 'securepoll-create-label-questions-add',
484            'fields' => $questionFields,
485            'disabled' => $isRunning,
486        ];
487
488        if ( $this->specialPage->getConfig()->get( 'SecurePollUseNamespace' ) ) {
489            $formItems['comment'] = [
490                'type' => 'text',
491                'label-message' => 'securepoll-create-label-comment',
492                'maxlength' => 250,
493            ];
494        }
495
496        // Set form field defaults from any existing election
497        if ( $this->election ) {
498            $existingFieldData = $this->getFormDataFromElection();
499            foreach ( $existingFieldData as $fieldName => $fieldValue ) {
500                if ( isset( $formItems[ $fieldName ] ) ) {
501                    $formItems[ $fieldName ]['default'] = $fieldValue;
502                }
503            }
504        }
505
506        $form = HTMLForm::factory(
507            'ooui',
508            $formItems,
509            $this->specialPage->getContext(),
510            $this->election ? 'securepoll-edit' : 'securepoll-create'
511        );
512
513        $form->setSubmitTextMsg(
514            $this->election ? 'securepoll-edit-action' : 'securepoll-create-action'
515        );
516        $form->setSubmitCallback(
517            [
518                $this,
519                $isRunning ? 'processInputDuringElection' : 'processInput'
520            ]
521        );
522        $form->prepareForm();
523
524        // If this isn't the result of a POST, load the data from the election
525        $request = $this->specialPage->getRequest();
526        if ( $this->election && !( $request->wasPosted() && $request->getCheck(
527                    'wpEditToken'
528                ) )
529        ) {
530            $form->mFieldData = $this->getFormDataFromElection();
531        }
532
533        $result = $form->tryAuthorizedSubmit();
534        if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
535            if ( $this->election ) {
536                $out->setPageTitleMsg( $this->msg( 'securepoll-edit-edited' ) );
537                $out->addWikiMsg( 'securepoll-edit-edited-text' );
538            } else {
539                $out->setPageTitleMsg( $this->msg( 'securepoll-create-created' ) );
540                $out->addWikiMsg( 'securepoll-create-created-text' );
541            }
542            $out->returnToMain( false, SpecialPage::getTitleFor( 'SecurePoll' ) );
543        } else {
544            $form->displayForm( $result );
545        }
546    }
547
548    /**
549     * @param array $formData
550     * @return Status
551     */
552    public function processInputDuringElection( $formData ) {
553        // If editing a poll while it's running, only allow certain fields to be updated
554        // For now only property_admins and return-url can be edited
555        $fields = [
556            'admins' => implode( '|', explode( "\n", $formData['property_admins'] ) ),
557            'return-url' => $formData['return-url']
558        ];
559
560        $originalFormData = [];
561        $securePollUseLogging = $this->specialPage->getConfig()->get( 'SecurePollUseLogging' );
562        if ( $securePollUseLogging ) {
563            // Store original form data for logging
564            $originalFormData = $this->getFormDataFromElection();
565        }
566
567        $dbw = $this->lbFactory->getMainLB()->getConnection( ILoadBalancer::DB_PRIMARY );
568        $dbw->startAtomic( __METHOD__ );
569        foreach ( $fields as $pr_key => $pr_value ) {
570            $dbw->newUpdateQueryBuilder()
571                ->update( 'securepoll_properties' )
572                ->set( [ 'pr_value' => $pr_value ] )
573                ->where( [
574                    'pr_entity' => $this->election->getId(),
575                    'pr_key' => $pr_key
576                ] )
577                ->caller( __METHOD__ )
578                ->execute();
579        }
580        $dbw->endAtomic( __METHOD__ );
581
582        // Log any changes to admins
583        if ( $securePollUseLogging ) {
584            $this->logAdminChanges( $originalFormData, $formData, $this->election->getId() );
585        }
586
587        $this->recordElectionToNamespace( $this->election->getId(), $formData );
588
589        return Status::newGood( $this->election->getId() );
590    }
591
592    /**
593     * @param array $formData
594     * @param HtmlForm|null $form
595     * @return Status
596     */
597    public function processInput( $formData, $form ) {
598        try {
599            $context = new Context;
600            $userId = $this->specialPage->getUser()->getId();
601            $store = new FormStore;
602            $context->setStore( $store );
603            $store->setFormData( $context, $formData, $userId );
604            $election = $context->getElection( $store->eId );
605
606            if ( $this->election && $store->eId !== (int)$this->election->getId() ) {
607                return Status::newFatal( 'securepoll-create-fail-bad-id' );
608            }
609
610            // Get a connection in autocommit mode so that it is possible to do
611            // explicit transactions on it (T287859)
612            $dbw = $this->lbFactory->getAutoCommitPrimaryConnection();
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                    // Use autocommit mode so that we can share connections with
635                    // the write code below
636                    $rdbw = $this->lbFactory->getAutoCommitPrimaryConnection( $dbname );
637
638                    // Find an existing dummy election, if any
639                    $rId = $rdbw->newSelectQueryBuilder()
640                        ->select( 'p1.pr_entity' )
641                        ->from( 'securepoll_properties', 'p1' )
642                        ->join( 'securepoll_properties', 'p2', 'p1.pr_entity = p2.pr_entity' )
643                        ->where( [
644                            'p1.pr_key' => 'jump-id',
645                            'p1.pr_value' => $election->getId(),
646                            'p2.pr_key' => 'main-wiki',
647                            'p2.pr_value' => WikiMap::getCurrentWikiId(),
648                        ] )
649                        ->caller( __METHOD__ )
650                        ->fetchField();
651                    // Test for duplicate title
652                    $id = $rdbw->newSelectQueryBuilder()
653                        ->select( 'el_entity' )
654                        ->from( 'securepoll_elections' )
655                        ->where( [
656                            'el_title' => $formData['election_title']
657                        ] )
658                        ->caller( __METHOD__ )
659                        ->fetchField();
660
661                    if ( $id && $id !== $rId ) {
662                        throw new StatusException(
663                            'securepoll-create-duplicate-title',
664                            FormStore::getWikiName( $dbname ),
665                            $dbname
666                        );
667                    }
668                }
669            }
670        } catch ( StatusException $ex ) {
671            return $ex->status;
672        }
673
674        $originalFormData = [];
675        $securePollUseLogging = $this->specialPage->getConfig()->get( 'SecurePollUseLogging' );
676        if ( $securePollUseLogging && $this->election ) {
677            // Store original form data for logging
678            $originalFormData = $this->getFormDataFromElection();
679        }
680
681        // Ok, begin the actual work
682        $dbw->startAtomic( __METHOD__ );
683        if ( $election->getId() > 0 ) {
684            $id = $dbw->newSelectQueryBuilder()
685                ->select( 'el_entity' )
686                ->from( 'securepoll_elections' )
687                ->where( [
688                    'el_entity' => $election->getId()
689                ] )
690                ->forUpdate()
691                ->caller( __METHOD__ )
692                ->fetchField();
693            if ( !$id ) {
694                $dbw->endAtomic( __METHOD__ );
695
696                return Status::newFatal( 'securepoll-create-fail-id-missing' );
697            }
698        }
699
700        // Insert or update the election entity
701        $fields = [
702            'el_title' => $election->title,
703            'el_ballot' => $election->ballotType,
704            'el_tally' => $election->tallyType,
705            'el_primary_lang' => $election->getLanguage(),
706            'el_start_date' => $dbw->timestamp( $election->getStartDate() ),
707            'el_end_date' => $dbw->timestamp( $election->getEndDate() ),
708            'el_auth_type' => $election->authType,
709            'el_owner' => $election->owner,
710        ];
711        if ( $election->getId() < 0 ) {
712            $eId = self::insertEntity( $dbw, 'election' );
713            $qIds = [];
714            $oIds = [];
715            $fields['el_entity'] = $eId;
716            $dbw->newInsertQueryBuilder()
717                ->insertInto( 'securepoll_elections' )
718                ->row( $fields )
719                ->caller( __METHOD__ )
720                ->execute();
721
722            // Enable sitewide block by default on new elections
723            $dbw->newInsertQueryBuilder()
724                ->insertInto( 'securepoll_properties' )
725                ->row( [
726                    'pr_entity' => $eId,
727                    'pr_key' => 'not-sitewide-blocked',
728                    'pr_value' => 1,
729                ] )
730                ->caller( __METHOD__ )
731                ->execute();
732        } else {
733            $eId = $election->getId();
734            $dbw->newUpdateQueryBuilder()
735                ->update( 'securepoll_elections' )
736                ->set( $fields )
737                ->where( [ 'el_entity' => $eId ] )
738                ->caller( __METHOD__ )
739                ->execute();
740
741            // Delete any questions or options that weren't included in the
742            // form submission.
743            $qIds = $dbw->newSelectQueryBuilder()
744                ->select( 'qu_entity' )
745                ->from( 'securepoll_questions' )
746                ->where( [ 'qu_election' => $eId ] )
747                ->caller( __METHOD__ )
748                ->fetchFieldValues();
749            $oIds = $dbw->newSelectQueryBuilder()
750                ->select( 'op_entity' )
751                ->from( 'securepoll_options' )
752                ->where( [ 'op_election' => $eId ] )
753                ->caller( __METHOD__ )
754                ->fetchFieldValues();
755            $deleteIds = array_merge(
756                array_diff( $qIds, $store->qIds ),
757                array_diff( $oIds, $store->oIds )
758            );
759            if ( $deleteIds ) {
760                $dbw->newDeleteQueryBuilder()
761                    ->deleteFrom( 'securepoll_msgs' )
762                    ->where( [ 'msg_entity' => $deleteIds ] )
763                    ->caller( __METHOD__ )
764                    ->execute();
765                $dbw->newDeleteQueryBuilder()
766                    ->deleteFrom( 'securepoll_properties' )
767                    ->where( [ 'pr_entity' => $deleteIds ] )
768                    ->caller( __METHOD__ )
769                    ->execute();
770                $dbw->newDeleteQueryBuilder()
771                    ->deleteFrom( 'securepoll_questions' )
772                    ->where( [ 'qu_entity' => $deleteIds ] )
773                    ->caller( __METHOD__ )
774                    ->execute();
775                $dbw->newDeleteQueryBuilder()
776                    ->deleteFrom( 'securepoll_options' )
777                    ->where( [ 'op_entity' => $deleteIds ] )
778                    ->caller( __METHOD__ )
779                    ->execute();
780                $dbw->newDeleteQueryBuilder()
781                    ->deleteFrom( 'securepoll_entity' )
782                    ->where( [ 'en_id' => $deleteIds ] )
783                    ->caller( __METHOD__ )
784                    ->execute();
785            }
786        }
787        self::savePropertiesAndMessages( $dbw, $eId, $election );
788
789        // Now do questions and options
790        $qIndex = 0;
791        foreach ( $election->getQuestions() as $question ) {
792            $qId = $question->getId();
793            if ( !in_array( $qId, $qIds ) ) {
794                $qId = self::insertEntity( $dbw, 'question' );
795            }
796            $dbw->newReplaceQueryBuilder()
797                ->replaceInto( 'securepoll_questions' )
798                ->uniqueIndexFields( 'qu_entity' )
799                ->row( [
800                    'qu_entity' => $qId,
801                    'qu_election' => $eId,
802                    'qu_index' => ++$qIndex,
803                ] )
804                ->caller( __METHOD__ )
805                ->execute();
806            self::savePropertiesAndMessages( $dbw, $qId, $question );
807
808            foreach ( $question->getOptions() as $option ) {
809                $oId = $option->getId();
810                if ( !in_array( $oId, $oIds ) ) {
811                    $oId = self::insertEntity( $dbw, 'option' );
812                }
813                $dbw->newReplaceQueryBuilder()
814                    ->replaceInto( 'securepoll_options' )
815                    ->uniqueIndexFields( 'op_entity' )
816                    ->row( [
817                        'op_entity' => $oId,
818                        'op_election' => $eId,
819                        'op_question' => $qId,
820                    ] )
821                    ->caller( __METHOD__ )
822                    ->execute();
823                self::savePropertiesAndMessages( $dbw, $oId, $option );
824            }
825        }
826        $dbw->endAtomic( __METHOD__ );
827
828        if ( $securePollUseLogging ) {
829            $this->logAdminChanges( $originalFormData, $formData, $eId );
830        }
831
832        // Create the "redirect" polls on foreign wikis
833        if ( $store->rId ) {
834            $election = $context->getElection( $store->rId );
835            foreach ( $store->remoteWikis as $dbname ) {
836                // As for the local wiki, request autocommit mode to get outer transaction scope
837                $dbw = $this->lbFactory->getAutoCommitPrimaryConnection( $dbname );
838                $dbw->startAtomic( __METHOD__ );
839                // Find an existing dummy election, if any
840                $rId = $dbw->newSelectQueryBuilder()
841                    ->select( 'p1.pr_entity' )
842                    ->from( 'securepoll_properties', 'p1' )
843                    ->join( 'securepoll_properties', 'p2', 'p1.pr_entity = p2.pr_entity' )
844                    ->where( [
845                        'p1.pr_key' => 'jump-id',
846                        'p1.pr_value' => $eId,
847                        'p2.pr_key' => 'main-wiki',
848                        'p2.pr_value' => WikiMap::getCurrentWikiId(),
849                    ] )
850                    ->caller( __METHOD__ )
851                    ->fetchField();
852                if ( !$rId ) {
853                    $rId = self::insertEntity( $dbw, 'election' );
854                }
855
856                // Insert it! We don't have to care about questions or options here.
857                $dbw->newReplaceQueryBuilder()
858                    ->replaceInto( 'securepoll_elections' )
859                    ->uniqueIndexFields( 'el_entity' )
860                    ->row( [
861                        'el_entity' => $rId,
862                        'el_title' => $election->title,
863                        'el_ballot' => $election->ballotType,
864                        'el_tally' => $election->tallyType,
865                        'el_primary_lang' => $election->getLanguage(),
866                        'el_start_date' => $dbw->timestamp( $election->getStartDate() ),
867                        'el_end_date' => $dbw->timestamp( $election->getEndDate() ),
868                        'el_auth_type' => $election->authType,
869                        'el_owner' => $election->owner,
870                    ] )
871                    ->caller( __METHOD__ )
872                    ->execute();
873                self::savePropertiesAndMessages( $dbw, $rId, $election );
874
875                // Fix jump-id
876                $dbw->newUpdateQueryBuilder()
877                    ->update( 'securepoll_properties' )
878                    ->set( [ 'pr_value' => $eId ] )
879                    ->where( [
880                        'pr_entity' => $rId,
881                        'pr_key' => 'jump-id'
882                    ] )
883                    ->caller( __METHOD__ )
884                    ->execute();
885                $dbw->endAtomic( __METHOD__ );
886            }
887        }
888
889        $this->recordElectionToNamespace( $eId, $formData );
890
891        return Status::newGood( $eId );
892    }
893
894    /**
895     * Record this election to the SecurePoll namespace, if so configured.
896     *
897     * @param int $eId election id
898     * @param array $formData
899     */
900    private function recordElectionToNamespace( $eId, $formData ) {
901        if ( $this->specialPage->getConfig()->get( 'SecurePollUseNamespace' ) ) {
902            // Create a new context to bypass caching.
903            $context = new Context;
904            // We may be inside a transaction, so force a primary DB connection (T209804)
905            $context->getStore()->setForcePrimary( true );
906
907            $election = $context->getElection( $eId );
908
909            [ $title, $content ] = SecurePollContentHandler::makeContentFromElection(
910                $election
911            );
912            $wp = $this->wikiPageFactory->newFromTitle( $title );
913            $wp->doUserEditContent(
914                $content,
915                $this->specialPage->getUser(),
916                $formData['comment']
917            );
918
919            [ $title, $content ] = SecurePollContentHandler::makeContentFromElection(
920                $election,
921                'msg/' . $election->getLanguage()
922            );
923            $wp = $this->wikiPageFactory->newFromTitle( $title );
924            $wp->doUserEditContent(
925                $content,
926                $this->specialPage->getUser(),
927                $formData['comment']
928            );
929        }
930    }
931
932    /**
933     * Log changes made to the admins of the election.
934     *
935     * @param array $originalFormData Empty array if no election exists
936     * @param array $formData
937     * @param int $electionId
938     */
939    private function logAdminChanges(
940        array $originalFormData,
941        array $formData,
942        int $electionId
943    ): void {
944        if ( isset( $originalFormData['property_admins'] ) ) {
945            $oldAdmins = explode( "\n", $originalFormData['property_admins'] );
946        } else {
947            $oldAdmins = [];
948        }
949        $newAdmins = explode( "\n", $formData['property_admins'] );
950
951        if ( $oldAdmins === $newAdmins ) {
952            return;
953        }
954
955        $actions = [
956            self::LOG_TYPE_ADDADMIN => array_diff( $newAdmins, $oldAdmins ),
957            self::LOG_TYPE_REMOVEADMIN => array_diff( $oldAdmins, $newAdmins ),
958        ];
959
960        $dbw = $this->lbFactory->getMainLB()->getConnection( ILoadBalancer::DB_PRIMARY );
961        $fields = [
962            'spl_timestamp' => $dbw->timestamp( time() ),
963            'spl_election_id' => $electionId,
964            'spl_user' => $this->specialPage->getUser()->getId(),
965        ];
966
967        foreach ( array_keys( $actions ) as $action ) {
968            foreach ( $actions[$action] as $admin ) {
969                $dbw->newInsertQueryBuilder()
970                    ->insertInto( 'securepoll_log' )
971                    ->row( $fields + [
972                        'spl_type' => $action,
973                        'spl_target' => $this->userFactory->newFromName( $admin )->getId(),
974                    ] )
975                    ->caller( __METHOD__ )
976                    ->execute();
977            }
978        }
979    }
980
981    /**
982     * Recreate the form data from an election
983     *
984     * @return array
985     */
986    private function getFormDataFromElection() {
987        $lang = $this->election->getLanguage();
988        $data = array_replace_recursive(
989            SecurePollContentHandler::getDataFromElection( $this->election, "msg/$lang" ),
990            SecurePollContentHandler::getDataFromElection( $this->election )
991        );
992        $p = &$data['properties'];
993        $m = &$data['messages'];
994
995        $startDate = new MWTimestamp( $data['startDate'] );
996        $endDate = new MWTimestamp( $data['endDate'] );
997
998        $ballot = $data['ballot'];
999        $tally = $data['tally'];
1000        $crypt = $p['encrypt-type'] ?? 'none';
1001
1002        $formData = [
1003            'election_id' => $data['id'],
1004            'election_title' => $data['title'],
1005            'property_wiki' => $p['wikis-val'] ?? null,
1006            'election_primaryLang' => $data['lang'],
1007            'election_startdate' => $startDate->format( 'Y-m-d\TH:i:s.0\Z' ),
1008            'election_enddate' => $endDate->format( 'Y-m-d\TH:i:s.0\Z' ),
1009            'return-url' => $p['return-url'] ?? null,
1010            'jump-text' => $m['jump-text'] ?? null,
1011            'election_type' => "{$ballot}+{$tally}",
1012            'election_crypt' => $crypt,
1013            'disallow-change' => isset( $p['disallow-change'] ) ? (bool)$p['disallow-change'] : null,
1014            'voter-privacy' => isset( $p['voter-privacy'] ) ? (bool)$p['voter-privacy'] : null,
1015            'property_admins' => '',
1016            'request-comment' => isset( $p['request-comment'] ) ? (bool)$p['request-comment'] : null,
1017            'prompt-active-wiki' => isset( $p['prompt-active-wiki'] ) ? (bool)$p['prompt-active-wiki'] : null,
1018            'comment-prompt' => $m['comment-prompt'] ?? null,
1019            'questions' => [],
1020            'comment' => '',
1021        ];
1022
1023        if ( isset( $data['properties']['admins'] ) ) {
1024            // HTMLUsersMultiselectField takes a line-separated string
1025            $formData['property_admins'] = implode( "\n", explode( '|', $data['properties']['admins'] ) );
1026        }
1027
1028        $classes = [];
1029        $tallyTypes = [];
1030        foreach ( $this->context->getBallotTypesForVote() as $class ) {
1031            $classes[] = $class;
1032            foreach ( $class::getTallyTypes() as $type ) {
1033                $tallyTypes[$type] = true;
1034            }
1035        }
1036        foreach ( Tallier::$tallierTypes as $type => $class ) {
1037            if ( isset( $tallyTypes[$type] ) ) {
1038                $classes[] = $class;
1039            }
1040        }
1041        foreach ( Crypt::getCryptTypes() as $class ) {
1042            if ( $class !== false ) {
1043                $classes[] = $class;
1044            }
1045        }
1046
1047        foreach ( $classes as $class ) {
1048            self::unprocessFormData( $formData, $data, $class, 'election' );
1049        }
1050
1051        foreach ( $data['questions'] as $question ) {
1052            $q = [
1053                'text' => $question['messages']['text'],
1054            ];
1055            if ( isset( $question['id'] ) ) {
1056                $q['id'] = $question['id'];
1057            }
1058
1059            foreach ( $classes as $class ) {
1060                self::unprocessFormData( $q, $question, $class, 'question' );
1061            }
1062
1063            // Process options for this question
1064            foreach ( $question['options'] as $option ) {
1065                $o = [
1066                    'text' => $option['messages']['text'],
1067                ];
1068                if ( isset( $option['id'] ) ) {
1069                    $o['id'] = $option['id'];
1070                }
1071
1072                foreach ( $classes as $class ) {
1073                    self::unprocessFormData( $o, $option, $class, 'option' );
1074                }
1075
1076                $q['options'][] = $o;
1077            }
1078
1079            $formData['questions'][] = $q;
1080        }
1081
1082        return $formData;
1083    }
1084
1085    /**
1086     * Insert an entry into the securepoll_entities table, and return the ID
1087     *
1088     * @param IDatabase $dbw
1089     * @param string $type Entity type
1090     * @return int
1091     */
1092    private static function insertEntity( $dbw, $type ) {
1093        $dbw->newInsertQueryBuilder()
1094            ->insertInto( 'securepoll_entity' )
1095            ->row( [
1096                'en_type' => $type,
1097            ] )
1098            ->caller( __METHOD__ )
1099            ->execute();
1100
1101        return $dbw->insertId();
1102    }
1103
1104    /**
1105     * Save properties and messages for an entity
1106     *
1107     * @param IDatabase $dbw
1108     * @param int $id
1109     * @param Entity $entity
1110     */
1111    private static function savePropertiesAndMessages( $dbw, $id, $entity ) {
1112        $properties = [];
1113        foreach ( $entity->getAllProperties() as $key => $value ) {
1114            $properties[] = [
1115                'pr_entity' => $id,
1116                'pr_key' => $key,
1117                'pr_value' => $value,
1118            ];
1119        }
1120        if ( $properties ) {
1121            $dbw->newReplaceQueryBuilder()
1122                ->replaceInto( 'securepoll_properties' )
1123                ->uniqueIndexFields( [ 'pr_entity', 'pr_key' ] )
1124                ->rows( $properties )
1125                ->caller( __METHOD__ )
1126                ->execute();
1127        }
1128
1129        $messages = [];
1130        $langs = $entity->getLangList();
1131        foreach ( $entity->getMessageNames() as $name ) {
1132            foreach ( $langs as $lang ) {
1133                $value = $entity->getRawMessage( $name, $lang );
1134                if ( $value !== false ) {
1135                    $messages[] = [
1136                        'msg_entity' => $id,
1137                        'msg_lang' => $lang,
1138                        'msg_key' => $name,
1139                        'msg_text' => $value,
1140                    ];
1141                }
1142            }
1143        }
1144        if ( $messages ) {
1145            $dbw->newReplaceQueryBuilder()
1146                ->replaceInto( 'securepoll_msgs' )
1147                ->uniqueIndexFields( [ 'msg_entity', 'msg_lang', 'msg_key' ] )
1148                ->rows( $messages )
1149                ->caller( __METHOD__ )
1150                ->execute();
1151        }
1152    }
1153
1154    /**
1155     * Combine form items for the class into the main array
1156     *
1157     * @param array &$outItems Array to insert the descriptors into
1158     * @param string $field Owning field name, for hide-if
1159     * @param string|array $types Type value(s) in the field, for hide-if
1160     * @param class-string<Ballot|Crypt|Tallier>|false $class
1161     * @param string|null $category If given, ::getCreateDescriptors is
1162     *    expected to return an array with subarrays for different categories
1163     *    of descriptors, and this selects which subarray to process.
1164     * @param bool|null $disabled Should the field be disabled
1165     */
1166    private static function processFormItems(
1167        &$outItems, $field, $types, $class,
1168        $category = null,
1169        $disabled = false
1170    ) {
1171        if ( $class === false ) {
1172            return;
1173        }
1174
1175        $items = $class::getCreateDescriptors();
1176
1177        if ( !is_array( $types ) ) {
1178            $types = [ $types ];
1179        }
1180
1181        if ( $category ) {
1182            if ( !isset( $items[$category] ) ) {
1183                return;
1184            }
1185            $items = $items[$category];
1186        }
1187
1188        foreach ( $items as $key => $item ) {
1189            if ( $disabled ) {
1190                $item['disabled'] = true;
1191            }
1192            if ( !isset( $outItems[$key] ) ) {
1193                if ( !isset( $item['hide-if'] ) ) {
1194                    $item['hide-if'] = [
1195                        'OR',
1196                        [ 'AND' ]
1197                    ];
1198                } else {
1199                    $item['hide-if'] = [
1200                        'OR',
1201                        [ 'AND' ],
1202                        $item['hide-if']
1203                    ];
1204                }
1205                $outItems[$key] = $item;
1206            } else {
1207                // @todo Detect if this is really the same descriptor?
1208            }
1209            foreach ( $types as $type ) {
1210                $outItems[$key]['hide-if'][1][] = [
1211                    '!==',
1212                    $field,
1213                    $type
1214                ];
1215            }
1216        }
1217    }
1218
1219    /**
1220     * Inject form field values for the class's properties and messages
1221     *
1222     * @param array &$formData Form data array
1223     * @param array $data Input data array
1224     * @param class-string<Ballot|Crypt|Tallier>|false $class
1225     * @param string|null $category If given, ::getCreateDescriptors is
1226     *    expected to return an array with subarrays for different categories
1227     *    of descriptors, and this selects which subarray to process.
1228     */
1229    private static function unprocessFormData( &$formData, $data, $class, $category ) {
1230        if ( $class === false ) {
1231            return;
1232        }
1233
1234        $items = $class::getCreateDescriptors();
1235
1236        if ( $category ) {
1237            if ( !isset( $items[$category] ) ) {
1238                return;
1239            }
1240            $items = $items[$category];
1241        }
1242
1243        foreach ( $items as $key => $item ) {
1244            if ( !isset( $item['SecurePoll_type'] ) ) {
1245                continue;
1246            }
1247            switch ( $item['SecurePoll_type'] ) {
1248                case 'property':
1249                    if ( isset( $data['properties'][$key] ) ) {
1250                        $formData[$key] = $data['properties'][$key];
1251                    } else {
1252                        $formData[$key] = null;
1253                    }
1254                    break;
1255                case 'properties':
1256                    $formData[$key] = [];
1257                    foreach ( $data['properties'] as $k => $v ) {
1258                        $formData[$key][$k] = $v;
1259                    }
1260                    break;
1261                case 'message':
1262                    if ( isset( $data['messages'][$key] ) ) {
1263                        $formData[$key] = $data['messages'][$key];
1264                    } else {
1265                        $formData[$key] = null;
1266                    }
1267                    break;
1268                case 'messages':
1269                    $formData[$key] = [];
1270                    foreach ( $data['messages'] as $k => $v ) {
1271                        $formData[$key][$k] = $v;
1272                    }
1273                    break;
1274            }
1275        }
1276    }
1277
1278    /**
1279     * Check that the user has the securepoll-edit-poll right
1280     *
1281     * @param string $value Username
1282     * @param array $alldata All form data
1283     * @param HTMLForm $containingForm Containing HTMLForm
1284     * @return bool|string true on success, string on error
1285     */
1286    public function checkEditPollRight( $value, $alldata, HTMLForm $containingForm ) {
1287        $user = $this->userFactory->newFromName( $value );
1288        if ( !$user || !$user->isAllowed( 'securepoll-edit-poll' ) ) {
1289            return $this->msg(
1290                'securepoll-create-user-missing-edit-right',
1291                $value
1292            )->parse();
1293        }
1294
1295        return true;
1296    }
1297
1298    /**
1299     * @param string $value
1300     * @param array $formData
1301     * @return string|true
1302     */
1303    public function checkElectionEndDate( $value, $formData ) {
1304        $startDate = new DateTime( $formData['election_startdate'], new DateTimeZone( 'GMT' ) );
1305        $endDate = new DateTime( $value, new DateTimeZone( 'GMT' ) );
1306
1307        if ( $startDate >= $endDate ) {
1308            return $this->msg( 'securepoll-htmlform-daterange-end-before-start' )->parseAsBlock();
1309        }
1310
1311        return true;
1312    }
1313
1314    /**
1315     * Check that a required field has been filled.
1316     *
1317     * This is a hack for using with cloner fields. Just setting required=true
1318     * breaks cloner fields when used with OOUI, in no-JS environments, because
1319     * the browser will prevent submission on clicking the remove button of an
1320     * empty field.
1321     *
1322     * @internal For use by the HTMLFormField
1323     * @param string $value
1324     * @return true|Message true on success, Message on error
1325     */
1326    public static function checkRequired( $value ) {
1327        if ( $value === '' ) {
1328            return Status::newFatal( 'htmlform-required' )->getMessage();
1329        }
1330        return true;
1331    }
1332}