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