Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
26.46% covered (danger)
26.46%
236 / 892
5.88% covered (danger)
5.88%
1 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
VoterEligibilityPage
26.46% covered (danger)
26.46%
236 / 892
5.88% covered (danger)
5.88%
1 / 17
6440.78
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 execute
40.00% covered (danger)
40.00%
16 / 40
0.00% covered (danger)
0.00%
0 / 1
43.10
 saveProperties
0.00% covered (danger)
0.00%
0 / 51
0.00% covered (danger)
0.00%
0 / 1
90
 getAutoCommitPrimaryConnectionForWiki
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
7.91
 fetchList
71.11% covered (warning)
71.11%
32 / 45
0.00% covered (danger)
0.00%
0 / 1
8.18
 saveList
70.73% covered (warning)
70.73%
58 / 82
0.00% covered (danger)
0.00%
0 / 1
17.24
 executeConfig
0.00% covered (danger)
0.00%
0 / 372
0.00% covered (danger)
0.00%
0 / 1
462
 parseDate
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 checkRequired
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 checkMin
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 checkCentralBlockThreshold
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 checkEditsBeforeCount
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 checkEditsBetweenCount
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 checkListEditsEndDate
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 processConfig
0.00% covered (danger)
0.00%
0 / 98
0.00% covered (danger)
0.00%
0 / 1
506
 executeEdit
82.69% covered (warning)
82.69%
43 / 52
0.00% covered (danger)
0.00%
0 / 1
8.33
 executeClear
78.79% covered (warning)
78.79%
78 / 99
0.00% covered (danger)
0.00%
0 / 1
10.95
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Pages;
4
5use DateTime;
6use DateTimeZone;
7use Exception;
8use MediaWiki\Deferred\DeferredUpdates;
9use MediaWiki\Exception\MWExceptionHandler;
10use MediaWiki\Extension\SecurePoll\Context;
11use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException;
12use MediaWiki\Extension\SecurePoll\Jobs\PopulateVoterListJob;
13use MediaWiki\Extension\SecurePoll\SecurePollContentHandler;
14use MediaWiki\Extension\SecurePoll\SpecialSecurePoll;
15use MediaWiki\HTMLForm\HTMLForm;
16use MediaWiki\Json\FormatJson;
17use MediaWiki\Linker\Linker;
18use MediaWiki\Linker\LinkRenderer;
19use MediaWiki\Message\Message;
20use MediaWiki\Page\WikiPageFactory;
21use MediaWiki\SpecialPage\SpecialPage;
22use MediaWiki\Status\Status;
23use MediaWiki\Title\TitleFactory;
24use MediaWiki\User\UserGroupManager;
25use MediaWiki\WikiMap\WikiMap;
26use Wikimedia\Rdbms\DBConnectionError;
27use Wikimedia\Rdbms\IDatabase;
28use Wikimedia\Rdbms\LBFactory;
29use Wikimedia\RequestTimeout\TimeoutException;
30
31/**
32 * Special:SecurePoll subpage for managing the voter list for a poll
33 */
34class VoterEligibilityPage extends ActionPage {
35    /** @var string[] */
36    private static $lists = [
37        'voter' => 'need-list',
38        'include' => 'include-list',
39        'exclude' => 'exclude-list',
40    ];
41
42    /** @var LBFactory */
43    private $lbFactory;
44
45    /** @var LinkRenderer */
46    private $linkRenderer;
47
48    /** @var TitleFactory */
49    private $titleFactory;
50
51    /** @var UserGroupManager */
52    private $userGroupManager;
53
54    /** @var WikiPageFactory */
55    private $wikiPageFactory;
56
57    /**
58     * @param SpecialSecurePoll $specialPage
59     * @param LBFactory $lbFactory
60     * @param LinkRenderer $linkRenderer
61     * @param TitleFactory $titleFactory
62     * @param UserGroupManager $userGroupManager
63     * @param WikiPageFactory $wikiPageFactory
64     */
65    public function __construct(
66        SpecialSecurePoll $specialPage,
67        LBFactory $lbFactory,
68        LinkRenderer $linkRenderer,
69        TitleFactory $titleFactory,
70        UserGroupManager $userGroupManager,
71        WikiPageFactory $wikiPageFactory
72    ) {
73        parent::__construct( $specialPage );
74        $this->lbFactory = $lbFactory;
75        $this->linkRenderer = $linkRenderer;
76        $this->titleFactory = $titleFactory;
77        $this->userGroupManager = $userGroupManager;
78        $this->wikiPageFactory = $wikiPageFactory;
79    }
80
81    /**
82     * Execute the subpage.
83     * @param array $params Array of subpage parameters.
84     */
85    public function execute( $params ) {
86        $out = $this->specialPage->getOutput();
87
88        if ( !count( $params ) ) {
89            $out->addWikiMsg( 'securepoll-too-few-params' );
90
91            return;
92        }
93
94        $electionId = intval( $params[0] );
95        $this->election = $this->context->getElection( $electionId );
96        if ( !$this->election ) {
97            $out->addWikiMsg( 'securepoll-invalid-election', $electionId );
98
99            return;
100        }
101        if ( !$this->election->isAdmin( $this->specialPage->getUser() ) ) {
102            $out->addWikiMsg( 'securepoll-need-admin' );
103
104            return;
105        }
106
107        $jumpUrl = $this->election->getProperty( 'jump-url' );
108        if ( $jumpUrl ) {
109            $jumpId = $this->election->getProperty( 'jump-id' );
110            if ( !$jumpId ) {
111                throw new InvalidDataException( 'Configuration error: no jump-id' );
112            }
113            $jumpUrl .= "/votereligibility/$jumpId";
114            if ( count( $params ) > 1 ) {
115                $jumpUrl .= '/' . implode( '/', array_slice( $params, 1 ) );
116            }
117
118            $wiki = $this->election->getProperty( 'main-wiki' );
119            if ( $wiki ) {
120                $wiki = WikiMap::getWikiName( $wiki );
121            } else {
122                $wiki = $this->msg( 'securepoll-votereligibility-redirect-otherwiki' )->text();
123            }
124
125            $out->addWikiMsg(
126                'securepoll-votereligibility-redirect',
127                Message::rawParam( Linker::makeExternalLink( $jumpUrl, $wiki ) )
128            );
129
130            return;
131        }
132
133        if ( count( $params ) >= 3 ) {
134            $operation = $params[1];
135        } else {
136            $operation = 'config';
137        }
138
139        switch ( $operation ) {
140            case 'edit':
141                $this->executeEdit( $params[2] );
142                break;
143            case 'clear':
144                $this->executeClear( $params[2] );
145                break;
146            default:
147                $this->executeConfig();
148                break;
149        }
150    }
151
152    /**
153     * @param string[] $properties
154     * @param array $delete
155     * @param string $comment
156     */
157    private function saveProperties( $properties, $delete, $comment ) {
158        $localWiki = WikiMap::getCurrentWikiId();
159        $wikis = $this->election->getProperty( 'wikis' );
160        if ( $wikis ) {
161            $wikis = explode( "\n", $wikis );
162            $i = array_search( $localWiki, $wikis );
163            if ( $i !== false ) {
164                unset( $wikis[$i] );
165            }
166            array_unshift( $wikis, $localWiki );
167        } else {
168            $wikis = [ $localWiki ];
169        }
170
171        foreach ( $wikis as $dbname ) {
172            $dbw = $this->getAutoCommitPrimaryConnectionForWiki( $dbname );
173            if ( $dbw === null ) {
174                continue;
175            }
176
177            $dbw->startAtomic( __METHOD__ );
178
179            $id = $dbw->newSelectQueryBuilder()
180                ->select( 'el_entity' )
181                ->from( 'securepoll_elections' )
182                ->where( [ 'el_title' => $this->election->title ] )
183                ->caller( __METHOD__ )
184                ->fetchField();
185            if ( $id ) {
186                $ins = [];
187                foreach ( $properties as $key => $value ) {
188                    $ins[] = [
189                        'pr_entity' => $id,
190                        'pr_key' => $key,
191                        'pr_value' => $value,
192                    ];
193                }
194
195                $dbw->newDeleteQueryBuilder()
196                    ->deleteFrom( 'securepoll_properties' )
197                    ->where( [
198                        'pr_entity' => $id,
199                        'pr_key' => array_merge( $delete, array_keys( $properties ) ),
200                    ] )
201                    ->caller( __METHOD__ )
202                    ->execute();
203
204                if ( $ins ) {
205                    $dbw->newInsertQueryBuilder()
206                        ->insertInto( 'securepoll_properties' )
207                        ->rows( $ins )
208                        ->caller( __METHOD__ )
209                        ->execute();
210                }
211            }
212
213            $dbw->endAtomic( __METHOD__ );
214        }
215
216        // Record this election to the SecurePoll namespace, if so configured.
217        if ( $this->specialPage->getConfig()->get( 'SecurePollUseNamespace' ) ) {
218            // Create a new context to bypass caching
219            $context = new Context;
220            $election = $context->getElection( $this->election->getId() );
221
222            [ $title, $content ] = SecurePollContentHandler::makeContentFromElection(
223                $election
224            );
225            $wp = $this->wikiPageFactory->newFromTitle( $title );
226            $wp->doUserEditContent( $content, $this->specialPage->getUser(), $comment );
227        }
228    }
229
230    /**
231     * Gets a primary autocommit connection for the given wiki. If this is not the local
232     * wiki then it only returns a primary DB connection if the wiki is not in read only mode.
233     *
234     * @param string $dbname The DB name which we want to get the primary DB connection for
235     * @return IDatabase|null A primary DB connection, or null only if the DB is in read-only mode
236     *   and is not a local wiki
237     */
238    private function getAutoCommitPrimaryConnectionForWiki( string $dbname ): ?IDatabase {
239        $dbw = $this->lbFactory->getAutoCommitPrimaryConnection( $dbname );
240
241        // If the wiki is not the current wiki then we need to check if the wiki is in read-only mode
242        // before we try to use the DB connection to perform updates.
243        if ( $dbname !== WikiMap::getCurrentWikiId() ) {
244            try {
245                if ( $dbw->isReadOnly() ) {
246                    return null;
247                }
248            } catch ( DBConnectionError $e ) {
249                MWExceptionHandler::logException( $e );
250                return null;
251            }
252        }
253
254        return $dbw;
255    }
256
257    /**
258     * @param string $property
259     * @param int $db
260     * @return string[]
261     */
262    private function fetchList( $property, $db = DB_REPLICA ) {
263        $wikis = $this->election->getProperty( 'wikis' );
264        $localWiki = WikiMap::getCurrentWikiId();
265        if ( $wikis ) {
266            $wikis = explode( "\n", $wikis );
267            if ( !in_array( $localWiki, $wikis ) ) {
268                $wikis[] = $localWiki;
269            }
270        } else {
271            $wikis = [ $localWiki ];
272        }
273
274        $names = [];
275        foreach ( $wikis as $dbname ) {
276            $lb = $this->lbFactory->getMainLB( $dbname );
277            $dbr = $lb->getConnection( $db, [], $dbname );
278
279            $id = $dbr->newSelectQueryBuilder()
280                ->select( 'el_entity' )
281                ->from( 'securepoll_elections' )
282                ->where( [
283                    'el_title' => $this->election->title
284                ] )
285                ->caller( __METHOD__ )
286                ->fetchField();
287            if ( !$id ) {
288                // WTF?
289                continue;
290            }
291            $list = $dbr->newSelectQueryBuilder()
292                ->select( 'pr_value' )
293                ->from( 'securepoll_properties' )
294                ->where( [
295                    'pr_entity' => $id,
296                    'pr_key' => $property,
297                ] )
298                ->caller( __METHOD__ )
299                ->fetchField();
300            if ( !$list ) {
301                continue;
302            }
303
304            $res = $dbr->newSelectQueryBuilder()
305                ->select( 'user_name' )
306                ->from( 'securepoll_lists' )
307                ->join( 'user', null, 'user_id=li_member' )
308                ->where( [
309                    'li_name' => $list,
310                ] )
311                ->caller( __METHOD__ )
312                ->fetchResultSet();
313            foreach ( $res as $row ) {
314                $names[] = str_replace( '_', ' ', $row->user_name ) . "@$dbname";
315            }
316        }
317        sort( $names );
318
319        return $names;
320    }
321
322    /**
323     * @param string $property
324     * @param string $names
325     * @param string $comment
326     */
327    private function saveList( $property, $names, $comment ) {
328        $localWiki = WikiMap::getCurrentWikiId();
329
330        $wikiNames = [ '*' => [] ];
331        foreach ( explode( "\n", $names ) as $name ) {
332            $name = trim( $name );
333            $i = strrpos( $name, '@' );
334            if ( $i === false ) {
335                $wiki = '*';
336            } else {
337                $wiki = trim( substr( $name, $i + 1 ) );
338                $name = trim( substr( $name, 0, $i ) );
339            }
340            if ( $wiki !== '' && $name !== '' ) {
341                $wikiNames[$wiki][] = str_replace( '_', ' ', $name );
342            }
343        }
344
345        $list = "{$this->election->getId()}/list/$property";
346
347        $wikis = $this->election->getProperty( 'wikis' );
348        if ( $wikis ) {
349            $wikis = explode( "\n", $wikis );
350            $i = array_search( $localWiki, $wikis );
351            if ( $i !== false ) {
352                unset( $wikis[$i] );
353            }
354            array_unshift( $wikis, $localWiki );
355        } else {
356            $wikis = [ $localWiki ];
357        }
358
359        foreach ( $wikis as $dbname ) {
360            $dbw = $this->getAutoCommitPrimaryConnectionForWiki( $dbname );
361            if ( $dbw === null ) {
362                continue;
363            }
364
365            $dbw->startAtomic( __METHOD__ );
366
367            $id = $dbw->newSelectQueryBuilder()
368                ->select( 'el_entity' )
369                ->from( 'securepoll_elections' )
370                ->where( [ 'el_title' => $this->election->title ] )
371                ->caller( __METHOD__ )
372                ->fetchField();
373            if ( $id ) {
374                $dbw->newReplaceQueryBuilder()
375                    ->replaceInto( 'securepoll_properties' )
376                    ->uniqueIndexFields( [ 'pr_entity', 'pr_key' ] )
377                    ->row( [
378                        'pr_entity' => $id,
379                        'pr_key' => $property,
380                        'pr_value' => $list,
381                    ] )
382                    ->caller( __METHOD__ )
383                    ->execute();
384
385                if ( isset( $wikiNames[$dbname] ) ) {
386                    $queryNames = array_merge( $wikiNames['*'], $wikiNames[$dbname] );
387                } else {
388                    $queryNames = $wikiNames['*'];
389                }
390
391                $dbw->newDeleteQueryBuilder()
392                    ->deleteFrom( 'securepoll_lists' )
393                    ->where( [ 'li_name' => $list ] )
394                    ->caller( __METHOD__ )
395                    ->execute();
396                if ( $queryNames ) {
397                    $dbw->insertSelect(
398                        'securepoll_lists',
399                        'user',
400                        [
401                            'li_name' => $dbw->addQuotes( $list ),
402                            'li_member' => 'user_id'
403                        ],
404                        [ 'user_name' => $queryNames ],
405                        __METHOD__
406                    );
407                }
408            }
409
410            $dbw->endAtomic( __METHOD__ );
411        }
412
413        // Record this election to the SecurePoll namespace, if so configured.
414        if ( $this->specialPage->getConfig()->get( 'SecurePollUseNamespace' ) ) {
415            // Create a new context to bypass caching
416            $context = new Context;
417            $election = $context->getElection( $this->election->getId() );
418
419            [ $title, $content ] = SecurePollContentHandler::makeContentFromElection(
420                $election
421            );
422            $wp = $this->wikiPageFactory->newFromTitle( $title );
423            $wp->doUserEditContent( $content, $this->specialPage->getUser(), $comment );
424
425            $json = FormatJson::encode(
426                $this->fetchList( $property, DB_PRIMARY ),
427                false,
428                FormatJson::ALL_OK
429            );
430            $title = $this->titleFactory->makeTitle( NS_SECUREPOLL, $list );
431            $wp = $this->wikiPageFactory->newFromTitle( $title );
432            $wp->doUserEditContent(
433                SecurePollContentHandler::makeContent( $json, $title, 'SecurePoll' ),
434                $this->specialPage->getUser(),
435                $comment
436            );
437        }
438    }
439
440    private function executeConfig() {
441        $out = $this->specialPage->getOutput();
442        $out->addModuleStyles( [
443            'mediawiki.widgets.TagMultiselectWidget.styles',
444            'ext.securepoll',
445        ] );
446        $out->setPageTitleMsg( $this->msg( 'securepoll-votereligibility-title' ) );
447
448        $formItems = [];
449
450        $formItems['default_submit'] = [
451            'section' => 'basic',
452            'type' => 'submit',
453            'buttonlabel' => 'submit',
454            'cssclass' => 'securepoll-default-submit'
455        ];
456
457        $formItems['min-edits'] = [
458            'section' => 'basic',
459            'label-message' => 'securepoll-votereligibility-label-min_edits',
460            'type' => 'int',
461            'min' => 0,
462            'default' => $this->election->getProperty( 'min-edits', '' ),
463        ];
464
465        $date = $this->election->getProperty( 'max-registration', '' );
466        if ( $date !== '' ) {
467            $date = gmdate( 'Y-m-d', (int)wfTimestamp( TS_UNIX, $date ) );
468        } else {
469            $date = gmdate( 'Y-m-d', strtotime( 'yesterday' ) );
470        }
471        $formItems['max-registration'] = [
472            'section' => 'basic',
473            'label-message' => 'securepoll-votereligibility-label-max_registration',
474            'type' => 'date',
475            'default' => $date,
476        ];
477
478        $formItems['not-sitewide-blocked'] = [
479            'section' => 'basic',
480            'type' => 'check',
481            'label-message' => 'securepoll-votereligibility-label-not_blocked_sitewide',
482            'default' => $this->election->getProperty( 'not-sitewide-blocked' ),
483        ];
484
485        $formItems['not-partial-blocked'] = [
486            'section' => 'basic',
487            'type' => 'check',
488            'label-message' => 'securepoll-votereligibility-label-not_blocked_partial',
489            'default' => $this->election->getProperty( 'not-partial-blocked' ),
490        ];
491
492        $formItems['not-centrally-blocked'] = [
493            'section' => 'basic',
494            'label-message' => 'securepoll-votereligibility-label-not_centrally_blocked',
495            'type' => 'check',
496            'hidelabel' => true,
497            'default' => $this->election->getProperty( 'not-centrally-blocked', false ),
498        ];
499
500        $formItems['central-block-threshold'] = [
501            'section' => 'basic',
502            'label-message' => 'securepoll-votereligibility-label-central_block_threshold',
503            'type' => 'int',
504            'validation-callback' => [
505                $this,
506                'checkCentralBlockThreshold',
507            ],
508            'hide-if' => [
509                '===',
510                'not-centrally-blocked',
511                ''
512            ],
513            'default' => $this->election->getProperty( 'central-block-threshold', '' ),
514        ];
515
516        $formItems['not-bot'] = [
517            'section' => 'basic',
518            'label-message' => 'securepoll-votereligibility-label-not_bot',
519            'type' => 'check',
520            'hidelabel' => true,
521            'default' => $this->election->getProperty( 'not-bot', false ),
522        ];
523
524        $userGroupOptions = [];
525        foreach ( $this->userGroupManager->listAllGroups() as $group ) {
526            $userGroupOptions[ 'group-' . $group ] = $group;
527        }
528
529        $formItems['allow-usergroups'] = [
530            'section' => 'basic',
531            'label-message' => 'securepoll-votereligibility-label-include_groups',
532            'allowArbitrary' => false,
533            'type' => 'multiselect',
534            'dropdown' => true,
535            'options-messages' => $userGroupOptions,
536            'default' => explode( '|', $this->election->getProperty( 'allow-usergroups', "" ) )
537        ];
538
539        foreach ( self::$lists as $list => $property ) {
540            $use = null;
541            $links = [];
542            if ( $list === 'voter' ) {
543                $complete = $this->election->getProperty( 'list_complete-count', 0 );
544                $total = $this->election->getProperty( 'list_total-count', 0 );
545                if ( $complete !== $total ) {
546                    $use = $this->msg( 'securepoll-votereligibility-label-processing' )->numParams(
547                            round( $complete * 100.0 / $total, 1 )
548                        )->numParams( $complete, $total );
549                    $links = [ 'clear' ];
550                }
551            }
552            if ( $use === null && $this->election->getProperty( $property ) ) {
553                $use = $this->msg( 'securepoll-votereligibility-label-inuse' );
554                $links = [
555                    'edit',
556                    'clear'
557                ];
558            }
559            if ( $use === null ) {
560                $use = $this->msg( 'securepoll-votereligibility-label-notinuse' );
561                $links = [ 'edit' ];
562            }
563
564            $formItems[] = [
565                'section' => "lists/$list",
566                'type' => 'info',
567                'raw' => true,
568                'default' => $use->parse(),
569            ];
570
571            $prefix = 'votereligibility/' . $this->election->getId();
572            foreach ( $links as $action ) {
573                $title = SpecialPage::getTitleFor( 'SecurePoll', "$prefix/$action/$list" );
574                $queryParams = [];
575
576                if ( $action === 'clear' ) {
577                    $queryParams[ 'token' ] = $this->specialPage->getContext()
578                        ->getCsrfTokenSet()->getToken();
579                }
580
581                $link = $this->linkRenderer->makeLink(
582                    $title,
583                    $this->msg( "securepoll-votereligibility-label-$action" )->text(),
584                    [],
585                    $queryParams
586                );
587                $formItems[] = [
588                    'section' => "lists/$list",
589                    'type' => 'info',
590                    'raw' => true,
591                    'default' => $link,
592                ];
593            }
594
595            if ( $list === 'voter' ) {
596                $formItems['list_populate'] = [
597                    'section' => "lists/$list",
598                    'label-message' => 'securepoll-votereligibility-label-populate',
599                    'type' => 'check',
600                    'hidelabel' => true,
601                    'default' => $this->election->getProperty( 'list_populate', false ),
602                ];
603
604                $formItems['list_edits-before'] = [
605                    'section' => "lists/$list",
606                    'label-message' => 'securepoll-votereligibility-label-edits_before',
607                    'type' => 'check',
608                    'default' => $this->election->getProperty( 'list_edits-before', false ),
609                    'hide-if' => [
610                        '===',
611                        'list_populate',
612                        ''
613                    ],
614                ];
615
616                $formItems['list_edits-before-count'] = [
617                    'section' => "lists/$list",
618                    'label-message' => 'securepoll-votereligibility-label-edits_before_count',
619                    'type' => 'int',
620                    'validation-callback' => [
621                        $this,
622                        'checkEditsBeforeCount',
623                    ],
624                    'hide-if' => [
625                        'OR',
626                        [
627                            '===',
628                            'list_populate',
629                            ''
630                        ],
631                        [
632                            '===',
633                            'list_edits-before',
634                            ''
635                        ],
636                    ],
637                    'default' => $this->election->getProperty( 'list_edits-before-count', '' ),
638                ];
639
640                $date = $this->election->getProperty( 'list_edits-before-date', '' );
641                if ( $date !== '' ) {
642                    $date = gmdate( 'Y-m-d', (int)wfTimestamp( TS_UNIX, $date ) );
643                } else {
644                    $date = gmdate( 'Y-m-d', strtotime( 'yesterday' ) );
645                }
646                $formItems['list_edits-before-date'] = [
647                    'section' => "lists/$list",
648                    'label-message' => 'securepoll-votereligibility-label-edits_before_date',
649                    'type' => 'date',
650                    'max' => gmdate( 'Y-m-d', strtotime( 'yesterday' ) ),
651                    'required' => true,
652                    'hide-if' => [
653                        'OR',
654                        [
655                            '===',
656                            'list_populate',
657                            ''
658                        ],
659                        [
660                            '===',
661                            'list_edits-before',
662                            ''
663                        ],
664                    ],
665                    'default' => $date,
666                ];
667
668                $formItems['list_edits-between'] = [
669                    'section' => "lists/$list",
670                    'label-message' => 'securepoll-votereligibility-label-edits_between',
671                    'type' => 'check',
672                    'hide-if' => [
673                        '===',
674                        'list_populate',
675                        ''
676                    ],
677                    'default' => $this->election->getProperty( 'list_edits-between', false ),
678                ];
679
680                $formItems['list_edits-between-count'] = [
681                    'section' => "lists/$list",
682                    'label-message' => 'securepoll-votereligibility-label-edits_between_count',
683                    'type' => 'int',
684                    'validation-callback' => [
685                        $this,
686                        'checkEditsBetweenCount',
687                    ],
688                    'hide-if' => [
689                        'OR',
690                        [
691                            '===',
692                            'list_populate',
693                            ''
694                        ],
695                        [
696                            '===',
697                            'list_edits-between',
698                            ''
699                        ],
700                    ],
701                    'default' => $this->election->getProperty( 'list_edits-between-count', '' ),
702                ];
703
704                $editCountStartDate = $this->election->getProperty( 'list_edits-startdate', '' );
705                if ( $editCountStartDate !== '' ) {
706                    $editCountStartDate = gmdate(
707                        'Y-m-d',
708                        (int)wfTimestamp( TS_UNIX, $editCountStartDate )
709                    );
710                }
711
712                $formItems['list_edits-startdate'] = [
713                    'section' => "lists/$list",
714                    'label-message' => 'securepoll-votereligibility-label-edits_startdate',
715                    'type' => 'date',
716                    'max' => gmdate( 'Y-m-d', strtotime( 'yesterday' ) ),
717                    'required' => true,
718                    'hide-if' => [
719                        'OR',
720                        [
721                            '===',
722                            'list_populate',
723                            ''
724                        ],
725                        [
726                            '===',
727                            'list_edits-between',
728                            ''
729                        ],
730                    ],
731                    'default' => $editCountStartDate,
732                ];
733
734                $editCountEndDate = $this->election->getProperty( 'list_edits-enddate', '' );
735                if ( $editCountEndDate === '' ) {
736                    $editCountEndDate = gmdate( 'Y-m-d', strtotime( 'yesterday' ) );
737                } else {
738                    $editCountEndDate = gmdate(
739                        'Y-m-d',
740                        (int)wfTimestamp( TS_UNIX, $editCountEndDate )
741                    );
742                }
743
744                $formItems['list_edits-enddate'] = [
745                    'section' => "lists/$list",
746                    'label-message' => 'securepoll-votereligibility-label-edits_enddate',
747                    'type' => 'date',
748                    'max' => gmdate( 'Y-m-d', strtotime( 'yesterday' ) ),
749                    'required' => true,
750                    'validation-callback' => [
751                        $this,
752                        'checkListEditsEndDate'
753                    ],
754                    'hide-if' => [
755                        'OR',
756                        [
757                            '===',
758                            'list_populate',
759                            ''
760                        ],
761                        [
762                            '===',
763                            'list_edits-between',
764                            ''
765                        ],
766                    ],
767                    'default' => $editCountEndDate,
768                ];
769
770                $groups = $this->election->getProperty( 'list_exclude-groups', [] );
771                if ( $groups ) {
772                    $groups = array_map(
773                        static function ( $group ) {
774                            return [ 'group' => $group ];
775                        },
776                        explode( '|', $groups )
777                    );
778                }
779                $formItems['list_exclude-groups'] = [
780                    'section' => "lists/$list",
781                    'label-message' => 'securepoll-votereligibility-label-exclude_groups',
782                    'type' => 'cloner',
783                    'format' => 'raw',
784                    'default' => $groups,
785                    'fields' => [
786                        'group' => [
787                            'type' => 'text',
788                            'required' => true,
789                        ],
790                    ],
791                    'hide-if' => [
792                        '===',
793                        'list_populate',
794                        ''
795                    ],
796                ];
797
798                $groups = $this->election->getProperty( 'list_include-groups', [] );
799                if ( $groups ) {
800                    $groups = array_map(
801                        static function ( $group ) {
802                            return [ 'group' => $group ];
803                        },
804                        explode( '|', $groups )
805                    );
806                }
807                $formItems['list_include-groups'] = [
808                    'section' => "lists/$list",
809                    'label-message' => 'securepoll-votereligibility-label-include_groups',
810                    'type' => 'cloner',
811                    'format' => 'raw',
812                    'default' => $groups,
813                    'fields' => [
814                        'group' => [
815                            'type' => 'text',
816                            'required' => true,
817                        ],
818                    ],
819                    'hide-if' => [
820                        '===',
821                        'list_populate',
822                        ''
823                    ],
824                ];
825            }
826        }
827
828        if ( $this->specialPage->getConfig()->get( 'SecurePollUseNamespace' ) ) {
829            $formItems['comment'] = [
830                'type' => 'text',
831                'label-message' => 'securepoll-votereligibility-label-comment',
832                'maxlength' => 250,
833            ];
834        }
835
836        $form = HTMLForm::factory(
837            'ooui',
838            $formItems,
839            $this->specialPage->getContext(),
840            'securepoll-votereligibility'
841        );
842        $form->addHeaderHtml(
843            $this->msg( 'securepoll-votereligibility-basic-info' )->parseAsBlock(),
844            'basic'
845        );
846        $form->addHeaderHtml(
847            $this->msg( 'securepoll-votereligibility-lists-info' )->parseAsBlock(),
848            'lists'
849        );
850
851        $form->setSubmitTextMsg( 'securepoll-votereligibility-action' );
852        $form->setSubmitCallback(
853            [
854                $this,
855                'processConfig'
856            ]
857        );
858        $result = $form->show();
859
860        if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
861            $out->setPageTitleMsg( $this->msg( 'securepoll-votereligibility-saved' ) );
862            $out->addWikiMsg( 'securepoll-votereligibility-saved-text' );
863            $out->returnToMain( false, SpecialPage::getTitleFor( 'SecurePoll' ) );
864        }
865    }
866
867    /**
868     * Based on HTMLDateRangeField::praseDate()
869     *
870     * @param string $value Date to be parsed
871     * @return int
872     */
873    protected function parseDate( $value ) {
874        $value = trim( $value );
875        $value .= ' T00:00:00+0000';
876
877        try {
878            $date = new DateTime( $value, new DateTimeZone( 'GMT' ) );
879
880            return $date->getTimestamp();
881        } catch ( TimeoutException $ex ) {
882            // Unfortunately DateTime throws a generic Exception, but we can't
883            // ignore an exception generated by the RequestTimeout library.
884            throw $ex;
885        } catch ( Exception $ex ) {
886            return 0;
887        }
888    }
889
890    /**
891     * Check that a required field has been filled.
892     *
893     * This is a hack to allow OOUI to work with no-JS environments,
894     * because the browser will prevent submission if fields that
895     * would be hidden by JS are required but not filled.
896     *
897     * @internal For use by the HTMLFormField
898     * @param string $value
899     * @return true|Message true on success, Message on error
900     */
901    public function checkRequired( $value ) {
902        if ( $value === '' ) {
903            return Status::newFatal( 'htmlform-required' )->getMessage();
904        }
905        return true;
906    }
907
908    /**
909     * Check that a field has a minimum value
910     *
911     * This is a hack that reimplements input[min] because the
912     * browser implementation implicitly makes the field required
913     * as well. Since the hide-if infrastructure doesn't manage
914     * conditional requirements, this re-implementation allows
915     * for hide-if-affected fields to display errors when they are
916     * relevant (as opposed to all the time, even if the field
917     * is not in use)
918     *
919     * @internal For use by the HTMLFormField
920     * @param int $value
921     * @param int $min
922     * @return bool|string true on success, string on error
923     */
924    public function checkMin( $value, $min ) {
925        if ( $value < $min ) {
926            return $this->msg( 'htmlform-int-toolow', $min )->parse();
927        }
928
929        return true;
930    }
931
932    /**
933     * Pass input automatically if the parent input is not checked
934     * Otherwise check that input exists and is not less than 1
935     *
936     * @internal For use by the HTMLFormField
937     * @param string $value
938     * @param mixed[] $formData
939     * @return bool|string true on success, string on error
940     */
941    public function checkCentralBlockThreshold( $value, $formData ) {
942        if ( !$formData['not-centrally-blocked'] ) {
943            return true;
944        }
945
946        $exists = $this->checkRequired( $value );
947        if ( $exists !== true ) {
948            return $exists;
949        }
950
951        return $this->checkMin( (int)$value, 1 );
952    }
953
954    /**
955     * Pass input automatically if the parent input is not checked
956     * Otherwise check that input exists and is not less than 1
957     *
958     * @internal For use by the HTMLFormField
959     * @param string $value
960     * @param mixed[] $formData
961     * @return bool|string true on success, string on error
962     */
963    public function checkEditsBeforeCount( $value, $formData ) {
964        if ( !$formData['list_edits-before'] ) {
965            return true;
966        }
967
968        $exists = $this->checkRequired( $value );
969        if ( $exists !== true ) {
970            return $exists;
971        }
972
973        return $this->checkMin( (int)$value, 1 );
974    }
975
976    /**
977     * Pass input automatically if the parent input is not checked
978     * Otherwise check that input exists and is not less than 1
979     *
980     * @internal For use by the HTMLFormField
981     * @param string $value
982     * @param mixed[] $formData
983     * @return bool|string true on success, string on error
984     */
985    public function checkEditsBetweenCount( $value, $formData ) {
986        if ( !$formData['list_edits-between'] ) {
987            return true;
988        }
989
990        $exists = $this->checkRequired( $value );
991        if ( $exists !== true ) {
992            return $exists;
993        }
994
995        return $this->checkMin( (int)$value, 1 );
996    }
997
998    /**
999     * Check the end date exists and is after the start date
1000     *
1001     * @internal For use by the HTMLFormField
1002     * @param string $value
1003     * @param mixed[] $formData
1004     * @return bool|string true on success, string on error
1005     */
1006    public function checkListEditsEndDate( $value, $formData ) {
1007        if ( !$formData['list_edits-between'] ) {
1008            return true;
1009        }
1010
1011        $startDate = $this->parseDate( $formData['list_edits-startdate'] );
1012        $endDate = $this->parseDate( $value );
1013
1014        if ( $startDate >= $endDate ) {
1015            return $this->msg( 'securepoll-htmlform-daterange-end-before-start' )->parseAsBlock();
1016        }
1017
1018        return true;
1019    }
1020
1021    /**
1022     * @param array $formData
1023     * @param HtmlForm $form
1024     * @return Status
1025     */
1026    public function processConfig( $formData, $form ) {
1027        static $props = [
1028            'min-edits',
1029            'not-sitewide-blocked',
1030            'not-partial-blocked',
1031            'not-centrally-blocked',
1032            'central-block-threshold',
1033            'not-bot',
1034            'list_populate',
1035            'list_edits-before',
1036            'list_edits-before-count',
1037            'list_edits-between',
1038            'list_edits-between-count',
1039        ];
1040        static $dateProps = [
1041            'max-registration',
1042            'list_edits-before-date',
1043            'list_edits-startdate',
1044            'list_edits-enddate',
1045        ];
1046        static $listProps = [
1047            'list_exclude-groups',
1048            'list_include-groups',
1049        ];
1050        static $multiselectProps = [
1051            'allow-usergroups'
1052        ];
1053
1054        static $propPrereqs = [
1055            'not-centrally-blocked' => [
1056                'central-block-threshold'
1057            ],
1058            'list_edits-before' => [
1059                'list_edits-before-count',
1060                'list_edits-before-date',
1061            ],
1062            'list_edits-between' => [
1063                'list_edits-between-count',
1064                'list_edits-startdate',
1065                'list_edits-enddate',
1066            ]
1067        ];
1068
1069        if ( $formData['list_populate'] &&
1070            !$formData['list_edits-before'] &&
1071            !$formData['list_edits-between'] &&
1072            !$formData['list_exclude-groups'] &&
1073            !$formData['list_include-groups']
1074        ) {
1075            return Status::newFatal( 'securepoll-votereligibility-fail-nothing-to-process' );
1076        }
1077
1078        $properties = [];
1079        $deleteProperties = [];
1080
1081        // Unset any properties where the parent property is not checked and
1082        // mark them for deletion from the database
1083        foreach ( $propPrereqs as $parentProp => $childrenProps ) {
1084            if ( $formData[$parentProp] === '' || $formData[$parentProp] === false ) {
1085                foreach ( $childrenProps as $childProp ) {
1086                    $formData[ $childProp ] = '';
1087                    $deleteProperties[] = $childProp;
1088                }
1089            }
1090        }
1091
1092        foreach ( $props as $prop ) {
1093            if (
1094                $formData[$prop] !== '' &&
1095                $formData[$prop] !== false
1096            ) {
1097                $properties[$prop] = $formData[$prop];
1098            } else {
1099                $deleteProperties[] = $prop;
1100            }
1101        }
1102
1103        foreach ( $dateProps as $prop ) {
1104            if ( $formData[$prop] !== '' && $formData[$prop] !== [] ) {
1105                $dates = array_map(
1106                    static function ( $date ) {
1107                        $date = new DateTime( $date, new DateTimeZone( 'GMT' ) );
1108
1109                        return wfTimestamp( TS_MW, $date->format( 'YmdHis' ) );
1110                    },
1111                    (array)$formData[$prop]
1112                );
1113                $properties[$prop] = implode( '|', $dates );
1114            } else {
1115                $deleteProperties[] = $prop;
1116            }
1117        }
1118
1119        foreach ( $listProps as $prop ) {
1120            if ( $formData[$prop] ) {
1121                $names = array_map(
1122                    static function ( $entry ) {
1123                        return $entry['group'];
1124                    },
1125                    $formData[$prop]
1126                );
1127                sort( $names );
1128                $properties[$prop] = implode( '|', $names );
1129            } else {
1130                $deleteProperties[] = $prop;
1131            }
1132        }
1133
1134        foreach ( $multiselectProps as $prop ) {
1135            if ( $formData[$prop] ) {
1136                $properties[$prop] = implode( '|', $formData[$prop] );
1137            } else {
1138                $deleteProperties[] = $prop;
1139            }
1140        }
1141
1142        // De-dupe the $deleteProperties array
1143        $deleteProperties = array_unique( $deleteProperties );
1144
1145        $populate = !empty( $properties['list_populate'] );
1146        if ( $populate ) {
1147            $properties['need-list'] = 'need-list-' . $this->election->getId();
1148        }
1149
1150        $comment = $formData['comment'] ?? '';
1151
1152        $this->saveProperties( $properties, $deleteProperties, $comment );
1153
1154        if ( $populate ) {
1155            // Run pushJobsForElection() in a deferred update to give it outer transaction
1156            // scope, but keep it presend, so that any errors bubble up to the user.
1157            DeferredUpdates::addCallableUpdate(
1158                function () {
1159                    PopulateVoterListJob::pushJobsForElection( $this->election );
1160                },
1161                DeferredUpdates::PRESEND
1162            );
1163        }
1164
1165        return Status::newGood();
1166    }
1167
1168    private function executeEdit( string $which ) {
1169        $out = $this->specialPage->getOutput();
1170
1171        if ( !isset( self::$lists[$which] ) ) {
1172            $out->addWikiMsg( 'securepoll-votereligibility-invalid-list' );
1173
1174            return;
1175        }
1176        $property = self::$lists[$which];
1177        $name = $this->msg( "securepoll-votereligibility-$which" )->text();
1178
1179        if ( $which === 'voter' ) {
1180            $complete = $this->election->getProperty( 'list_complete-count', 0 );
1181            $total = $this->election->getProperty( 'list_total-count', 0 );
1182            if ( $complete !== $total ) {
1183                $out->addWikiMsg( 'securepoll-votereligibility-list-is-processing' );
1184
1185                return;
1186            }
1187        }
1188
1189        $out->addModuleStyles( 'ext.securepoll' );
1190        $out->setPageTitleMsg( $this->msg( 'securepoll-votereligibility-edit-title', $name ) );
1191
1192        $formItems = [];
1193
1194        $formItems['names'] = [
1195            'label-message' => 'securepoll-votereligibility-label-names',
1196            'type' => 'textarea',
1197            'rows' => 20,
1198            'default' => implode( "\n", $this->fetchList( $property ) ),
1199        ];
1200
1201        if ( $this->specialPage->getConfig()->get( 'SecurePollUseNamespace' ) ) {
1202            $formItems['comment'] = [
1203                'type' => 'text',
1204                'label-message' => 'securepoll-votereligibility-label-comment',
1205                'maxlength' => 250,
1206            ];
1207        }
1208
1209        $form = new HTMLForm(
1210            $formItems, $this->specialPage->getContext(), 'securepoll-votereligibility'
1211        );
1212        $form->addHeaderHtml(
1213            $this->msg( 'securepoll-votereligibility-edit-header' )->parseAsBlock()
1214        );
1215        $form->setDisplayFormat( 'div' );
1216        $form->setSubmitTextMsg( 'securepoll-votereligibility-edit-action' );
1217        $form->setSubmitCallback(
1218            function ( $formData, $form ) use ( $property ) {
1219                $this->saveList( $property, $formData['names'], $formData['comment'] ?? '' );
1220
1221                return Status::newGood();
1222            }
1223        );
1224        $result = $form->show();
1225
1226        if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
1227            $out->setPageTitleMsg( $this->msg( 'securepoll-votereligibility-saved' ) );
1228            $out->addWikiMsg( 'securepoll-votereligibility-saved-text' );
1229            $out->returnToMain(
1230                false,
1231                SpecialPage::getTitleFor(
1232                    'SecurePoll',
1233                    'votereligibility/' . $this->election->getId()
1234                )
1235            );
1236        }
1237    }
1238
1239    private function executeClear( string $which ) {
1240        $out = $this->specialPage->getOutput();
1241        $localWiki = WikiMap::getCurrentWikiId();
1242
1243        $token = $this->specialPage->getContext()->getCsrfTokenSet()->getToken();
1244        $request = $this->specialPage->getRequest();
1245        $tokenMatch = $token->match( $request->getVal( 'token' ) );
1246        if ( !$tokenMatch ) {
1247            $out->addWikiMsg( 'securepoll-votereligibility-token-mismatch' );
1248            return;
1249        }
1250
1251        if ( !isset( self::$lists[$which] ) ) {
1252            $out->addWikiMsg( 'securepoll-votereligibility-invalid-list' );
1253
1254            return;
1255        }
1256        $property = self::$lists[$which];
1257        $name = $this->msg( "securepoll-votereligibility-$which" )->text();
1258
1259        $out = $this->specialPage->getOutput();
1260        $out->setPageTitleMsg( $this->msg( 'securepoll-votereligibility-clear-title', $name ) );
1261
1262        $wikis = $this->election->getProperty( 'wikis' );
1263        if ( $wikis ) {
1264            $wikis = explode( "\n", $wikis );
1265            $i = array_search( $localWiki, $wikis );
1266            if ( $i !== false ) {
1267                unset( $wikis[$i] );
1268            }
1269            array_unshift( $wikis, $localWiki );
1270        } else {
1271            $wikis = [ $localWiki ];
1272        }
1273
1274        foreach ( $wikis as $dbname ) {
1275            $dbw = $this->lbFactory->getAutoCommitPrimaryConnection( $dbname );
1276            $dbw->startAtomic( __METHOD__ );
1277
1278            $id = $dbw->newSelectQueryBuilder()
1279                ->select( 'el_entity' )
1280                ->from( 'securepoll_elections' )
1281                ->where( [
1282                    'el_title' => $this->election->title
1283                ] )
1284                ->caller( __METHOD__ )
1285                ->fetchField();
1286            if ( $id ) {
1287                $list = $dbw->newSelectQueryBuilder()
1288                    ->select( 'pr_value' )
1289                    ->from( 'securepoll_properties' )
1290                    ->where( [
1291                        'pr_entity' => $id,
1292                        'pr_key' => $property,
1293                    ] )
1294                    ->caller( __METHOD__ )
1295                    ->fetchField();
1296                if ( $list ) {
1297                    $dbw->newDeleteQueryBuilder()
1298                        ->deleteFrom( 'securepoll_lists' )
1299                        ->where( [ 'li_name' => $list ] )
1300                        ->caller( __METHOD__ )
1301                        ->execute();
1302                    $dbw->newDeleteQueryBuilder()
1303                        ->deleteFrom( 'securepoll_properties' )
1304                        ->where( [
1305                            'pr_entity' => $id,
1306                            'pr_key' => $property
1307                        ] )
1308                        ->caller( __METHOD__ )
1309                        ->execute();
1310                }
1311
1312                if ( $which === 'voter' ) {
1313                    $dbw->newDeleteQueryBuilder()
1314                        ->deleteFrom( 'securepoll_properties' )
1315                        ->where( [
1316                            'pr_entity' => $id,
1317                            'pr_key' => [
1318                                'list_populate',
1319                                'list_job-key',
1320                                'list_total-count',
1321                                'list_complete-count',
1322                                'list_job-key',
1323                            ],
1324                        ] )
1325                        ->caller( __METHOD__ )
1326                        ->execute();
1327                }
1328            }
1329
1330            $dbw->endAtomic( __METHOD__ );
1331        }
1332
1333        // Record this election to the SecurePoll namespace, if so configured.
1334        if ( $this->specialPage->getConfig()->get( 'SecurePollUseNamespace' ) ) {
1335            // Create a new context to bypass caching
1336            $context = new Context;
1337            $election = $context->getElection( $this->election->getId() );
1338
1339            [ $title, $content ] = SecurePollContentHandler::makeContentFromElection(
1340                $election
1341            );
1342            $wp = $this->wikiPageFactory->newFromTitle( $title );
1343            $wp->doUserEditContent(
1344                $content,
1345                $this->specialPage->getUser(),
1346                $this->msg( 'securepoll-votereligibility-cleared-comment', $name )->text()
1347            );
1348
1349            $title = $this->titleFactory->makeTitle( NS_SECUREPOLL, "{$election->getId()}/list/$property" );
1350            $wp = $this->wikiPageFactory->newFromTitle( $title );
1351            $wp->doUserEditContent(
1352                SecurePollContentHandler::makeContent( '[]', $title, 'SecurePoll' ),
1353                $this->specialPage->getUser(),
1354                $this->msg( 'securepoll-votereligibility-cleared-comment', $name )->text()
1355            );
1356        }
1357
1358        $out->setPageTitleMsg( $this->msg( 'securepoll-votereligibility-cleared' ) );
1359        $out->addWikiMsg( 'securepoll-votereligibility-cleared-text', $name );
1360        $out->returnToMain(
1361            false,
1362            SpecialPage::getTitleFor( 'SecurePoll', 'votereligibility/' . $this->election->getId() )
1363        );
1364    }
1365}