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