Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 878
0.00% covered (danger)
0.00%
0 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
VoterEligibilityPage
0.00% covered (danger)
0.00%
0 / 878
0.00% covered (danger)
0.00%
0 / 17
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 / 51
0.00% covered (danger)
0.00%
0 / 1
90
 getAutoCommitPrimaryConnectionForWiki
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 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 / 82
0.00% covered (danger)
0.00%
0 / 1
182
 executeConfig
0.00% covered (danger)
0.00%
0 / 364
0.00% covered (danger)
0.00%
0 / 1
420
 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 / 93
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2
3namespace MediaWiki\Extension\SecurePoll\Pages;
4
5use DateTime;
6use DateTimeZone;
7use Exception;
8use MediaWiki\Deferred\DeferredUpdates;
9use MediaWiki\Extension\SecurePoll\Context;
10use MediaWiki\Extension\SecurePoll\Exceptions\InvalidDataException;
11use MediaWiki\Extension\SecurePoll\Jobs\PopulateVoterListJob;
12use MediaWiki\Extension\SecurePoll\SecurePollContentHandler;
13use MediaWiki\Extension\SecurePoll\SpecialSecurePoll;
14use MediaWiki\HTMLForm\HTMLForm;
15use MediaWiki\Json\FormatJson;
16use MediaWiki\Linker\Linker;
17use MediaWiki\Linker\LinkRenderer;
18use MediaWiki\Message\Message;
19use MediaWiki\Page\WikiPageFactory;
20use MediaWiki\SpecialPage\SpecialPage;
21use MediaWiki\Status\Status;
22use MediaWiki\Title\TitleFactory;
23use MediaWiki\User\UserGroupManager;
24use MediaWiki\WikiMap\WikiMap;
25use MWExceptionHandler;
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                $link = $this->linkRenderer->makeLink( $title,
575                    $this->msg( "securepoll-votereligibility-label-$action" )->text() );
576                $formItems[] = [
577                    'section' => "lists/$list",
578                    'type' => 'info',
579                    'raw' => true,
580                    'default' => $link,
581                ];
582            }
583
584            if ( $list === 'voter' ) {
585                $formItems['list_populate'] = [
586                    'section' => "lists/$list",
587                    'label-message' => 'securepoll-votereligibility-label-populate',
588                    'type' => 'check',
589                    'hidelabel' => true,
590                    'default' => $this->election->getProperty( 'list_populate', false ),
591                ];
592
593                $formItems['list_edits-before'] = [
594                    'section' => "lists/$list",
595                    'label-message' => 'securepoll-votereligibility-label-edits_before',
596                    'type' => 'check',
597                    'default' => $this->election->getProperty( 'list_edits-before', false ),
598                    'hide-if' => [
599                        '===',
600                        'list_populate',
601                        ''
602                    ],
603                ];
604
605                $formItems['list_edits-before-count'] = [
606                    'section' => "lists/$list",
607                    'label-message' => 'securepoll-votereligibility-label-edits_before_count',
608                    'type' => 'int',
609                    'validation-callback' => [
610                        $this,
611                        'checkEditsBeforeCount',
612                    ],
613                    'hide-if' => [
614                        'OR',
615                        [
616                            '===',
617                            'list_populate',
618                            ''
619                        ],
620                        [
621                            '===',
622                            'list_edits-before',
623                            ''
624                        ],
625                    ],
626                    'default' => $this->election->getProperty( 'list_edits-before-count', '' ),
627                ];
628
629                $date = $this->election->getProperty( 'list_edits-before-date', '' );
630                if ( $date !== '' ) {
631                    $date = gmdate( 'Y-m-d', (int)wfTimestamp( TS_UNIX, $date ) );
632                } else {
633                    $date = gmdate( 'Y-m-d', strtotime( 'yesterday' ) );
634                }
635                $formItems['list_edits-before-date'] = [
636                    'section' => "lists/$list",
637                    'label-message' => 'securepoll-votereligibility-label-edits_before_date',
638                    'type' => 'date',
639                    'max' => gmdate( 'Y-m-d', strtotime( 'yesterday' ) ),
640                    'required' => true,
641                    'hide-if' => [
642                        'OR',
643                        [
644                            '===',
645                            'list_populate',
646                            ''
647                        ],
648                        [
649                            '===',
650                            'list_edits-before',
651                            ''
652                        ],
653                    ],
654                    'default' => $date,
655                ];
656
657                $formItems['list_edits-between'] = [
658                    'section' => "lists/$list",
659                    'label-message' => 'securepoll-votereligibility-label-edits_between',
660                    'type' => 'check',
661                    'hide-if' => [
662                        '===',
663                        'list_populate',
664                        ''
665                    ],
666                    'default' => $this->election->getProperty( 'list_edits-between', false ),
667                ];
668
669                $formItems['list_edits-between-count'] = [
670                    'section' => "lists/$list",
671                    'label-message' => 'securepoll-votereligibility-label-edits_between_count',
672                    'type' => 'int',
673                    'validation-callback' => [
674                        $this,
675                        'checkEditsBetweenCount',
676                    ],
677                    'hide-if' => [
678                        'OR',
679                        [
680                            '===',
681                            'list_populate',
682                            ''
683                        ],
684                        [
685                            '===',
686                            'list_edits-between',
687                            ''
688                        ],
689                    ],
690                    'default' => $this->election->getProperty( 'list_edits-between-count', '' ),
691                ];
692
693                $editCountStartDate = $this->election->getProperty( 'list_edits-startdate', '' );
694                if ( $editCountStartDate !== '' ) {
695                    $editCountStartDate = gmdate(
696                        'Y-m-d',
697                        (int)wfTimestamp( TS_UNIX, $editCountStartDate )
698                    );
699                }
700
701                $formItems['list_edits-startdate'] = [
702                    'section' => "lists/$list",
703                    'label-message' => 'securepoll-votereligibility-label-edits_startdate',
704                    'type' => 'date',
705                    'max' => gmdate( 'Y-m-d', strtotime( 'yesterday' ) ),
706                    'required' => true,
707                    'hide-if' => [
708                        'OR',
709                        [
710                            '===',
711                            'list_populate',
712                            ''
713                        ],
714                        [
715                            '===',
716                            'list_edits-between',
717                            ''
718                        ],
719                    ],
720                    'default' => $editCountStartDate,
721                ];
722
723                $editCountEndDate = $this->election->getProperty( 'list_edits-enddate', '' );
724                if ( $editCountEndDate === '' ) {
725                    $editCountEndDate = gmdate( 'Y-m-d', strtotime( 'yesterday' ) );
726                } else {
727                    $editCountEndDate = gmdate(
728                        'Y-m-d',
729                        (int)wfTimestamp( TS_UNIX, $editCountEndDate )
730                    );
731                }
732
733                $formItems['list_edits-enddate'] = [
734                    'section' => "lists/$list",
735                    'label-message' => 'securepoll-votereligibility-label-edits_enddate',
736                    'type' => 'date',
737                    'max' => gmdate( 'Y-m-d', strtotime( 'yesterday' ) ),
738                    'required' => true,
739                    'validation-callback' => [
740                        $this,
741                        'checkListEditsEndDate'
742                    ],
743                    'hide-if' => [
744                        'OR',
745                        [
746                            '===',
747                            'list_populate',
748                            ''
749                        ],
750                        [
751                            '===',
752                            'list_edits-between',
753                            ''
754                        ],
755                    ],
756                    'default' => $editCountEndDate,
757                ];
758
759                $groups = $this->election->getProperty( 'list_exclude-groups', [] );
760                if ( $groups ) {
761                    $groups = array_map(
762                        static function ( $group ) {
763                            return [ 'group' => $group ];
764                        },
765                        explode( '|', $groups )
766                    );
767                }
768                $formItems['list_exclude-groups'] = [
769                    'section' => "lists/$list",
770                    'label-message' => 'securepoll-votereligibility-label-exclude_groups',
771                    'type' => 'cloner',
772                    'format' => 'raw',
773                    'default' => $groups,
774                    'fields' => [
775                        'group' => [
776                            'type' => 'text',
777                            'required' => true,
778                        ],
779                    ],
780                    'hide-if' => [
781                        '===',
782                        'list_populate',
783                        ''
784                    ],
785                ];
786
787                $groups = $this->election->getProperty( 'list_include-groups', [] );
788                if ( $groups ) {
789                    $groups = array_map(
790                        static function ( $group ) {
791                            return [ 'group' => $group ];
792                        },
793                        explode( '|', $groups )
794                    );
795                }
796                $formItems['list_include-groups'] = [
797                    'section' => "lists/$list",
798                    'label-message' => 'securepoll-votereligibility-label-include_groups',
799                    'type' => 'cloner',
800                    'format' => 'raw',
801                    'default' => $groups,
802                    'fields' => [
803                        'group' => [
804                            'type' => 'text',
805                            'required' => true,
806                        ],
807                    ],
808                    'hide-if' => [
809                        '===',
810                        'list_populate',
811                        ''
812                    ],
813                ];
814            }
815        }
816
817        if ( $this->specialPage->getConfig()->get( 'SecurePollUseNamespace' ) ) {
818            $formItems['comment'] = [
819                'type' => 'text',
820                'label-message' => 'securepoll-votereligibility-label-comment',
821                'maxlength' => 250,
822            ];
823        }
824
825        $form = HTMLForm::factory(
826            'ooui',
827            $formItems,
828            $this->specialPage->getContext(),
829            'securepoll-votereligibility'
830        );
831        $form->addHeaderHtml(
832            $this->msg( 'securepoll-votereligibility-basic-info' )->parseAsBlock(),
833            'basic'
834        );
835        $form->addHeaderHtml(
836            $this->msg( 'securepoll-votereligibility-lists-info' )->parseAsBlock(),
837            'lists'
838        );
839
840        $form->setSubmitTextMsg( 'securepoll-votereligibility-action' );
841        $form->setSubmitCallback(
842            [
843                $this,
844                'processConfig'
845            ]
846        );
847        $result = $form->show();
848
849        if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
850            $out->setPageTitleMsg( $this->msg( 'securepoll-votereligibility-saved' ) );
851            $out->addWikiMsg( 'securepoll-votereligibility-saved-text' );
852            $out->returnToMain( false, SpecialPage::getTitleFor( 'SecurePoll' ) );
853        }
854    }
855
856    /**
857     * Based on HTMLDateRangeField::praseDate()
858     *
859     * @param string $value Date to be parsed
860     * @return int
861     */
862    protected function parseDate( $value ) {
863        $value = trim( $value );
864        $value .= ' T00:00:00+0000';
865
866        try {
867            $date = new DateTime( $value, new DateTimeZone( 'GMT' ) );
868
869            return $date->getTimestamp();
870        } catch ( TimeoutException $ex ) {
871            // Unfortunately DateTime throws a generic Exception, but we can't
872            // ignore an exception generated by the RequestTimeout library.
873            throw $ex;
874        } catch ( Exception $ex ) {
875            return 0;
876        }
877    }
878
879    /**
880     * Check that a required field has been filled.
881     *
882     * This is a hack to allow OOUI to work with no-JS environments,
883     * because the browser will prevent submission if fields that
884     * would be hidden by JS are required but not filled.
885     *
886     * @internal For use by the HTMLFormField
887     * @param string $value
888     * @return true|Message true on success, Message on error
889     */
890    public function checkRequired( $value ) {
891        if ( $value === '' ) {
892            return Status::newFatal( 'htmlform-required' )->getMessage();
893        }
894        return true;
895    }
896
897    /**
898     * Check that a field has a minimum value
899     *
900     * This is a hack that reimplements input[min] because the
901     * browser implementation implicitly makes the field required
902     * as well. Since the hide-if infrastructure doesn't manage
903     * conditional requirements, this re-implementation allows
904     * for hide-if-affected fields to display errors when they are
905     * relevant (as opposed to all the time, even if the field
906     * is not in use)
907     *
908     * @internal For use by the HTMLFormField
909     * @param int $value
910     * @param int $min
911     * @return bool|string true on success, string on error
912     */
913    public function checkMin( $value, $min ) {
914        if ( $value < $min ) {
915            return $this->msg( 'htmlform-int-toolow', $min )->parse();
916        }
917
918        return true;
919    }
920
921    /**
922     * Pass input automatically if the parent input is not checked
923     * Otherwise check that input exists and is not less than 1
924     *
925     * @internal For use by the HTMLFormField
926     * @param string $value
927     * @param mixed[] $formData
928     * @return bool|string true on success, string on error
929     */
930    public function checkCentralBlockThreshold( $value, $formData ) {
931        if ( !$formData['not-centrally-blocked'] ) {
932            return true;
933        }
934
935        $exists = $this->checkRequired( $value );
936        if ( $exists !== true ) {
937            return $exists;
938        }
939
940        return $this->checkMin( (int)$value, 1 );
941    }
942
943    /**
944     * Pass input automatically if the parent input is not checked
945     * Otherwise check that input exists and is not less than 1
946     *
947     * @internal For use by the HTMLFormField
948     * @param string $value
949     * @param mixed[] $formData
950     * @return bool|string true on success, string on error
951     */
952    public function checkEditsBeforeCount( $value, $formData ) {
953        if ( !$formData['list_edits-before'] ) {
954            return true;
955        }
956
957        $exists = $this->checkRequired( $value );
958        if ( $exists !== true ) {
959            return $exists;
960        }
961
962        return $this->checkMin( (int)$value, 1 );
963    }
964
965    /**
966     * Pass input automatically if the parent input is not checked
967     * Otherwise check that input exists and is not less than 1
968     *
969     * @internal For use by the HTMLFormField
970     * @param string $value
971     * @param mixed[] $formData
972     * @return bool|string true on success, string on error
973     */
974    public function checkEditsBetweenCount( $value, $formData ) {
975        if ( !$formData['list_edits-between'] ) {
976            return true;
977        }
978
979        $exists = $this->checkRequired( $value );
980        if ( $exists !== true ) {
981            return $exists;
982        }
983
984        return $this->checkMin( (int)$value, 1 );
985    }
986
987    /**
988     * Check the end date exists and is after the start date
989     *
990     * @internal For use by the HTMLFormField
991     * @param string $value
992     * @param mixed[] $formData
993     * @return bool|string true on success, string on error
994     */
995    public function checkListEditsEndDate( $value, $formData ) {
996        if ( !$formData['list_edits-between'] ) {
997            return true;
998        }
999
1000        $startDate = $this->parseDate( $formData['list_edits-startdate'] );
1001        $endDate = $this->parseDate( $value );
1002
1003        if ( $startDate >= $endDate ) {
1004            return $this->msg( 'securepoll-htmlform-daterange-end-before-start' )->parseAsBlock();
1005        }
1006
1007        return true;
1008    }
1009
1010    /**
1011     * @param array $formData
1012     * @param HtmlForm $form
1013     * @return Status
1014     */
1015    public function processConfig( $formData, $form ) {
1016        static $props = [
1017            'min-edits',
1018            'not-sitewide-blocked',
1019            'not-partial-blocked',
1020            'not-centrally-blocked',
1021            'central-block-threshold',
1022            'not-bot',
1023            'list_populate',
1024            'list_edits-before',
1025            'list_edits-before-count',
1026            'list_edits-between',
1027            'list_edits-between-count',
1028        ];
1029        static $dateProps = [
1030            'max-registration',
1031            'list_edits-before-date',
1032            'list_edits-startdate',
1033            'list_edits-enddate',
1034        ];
1035        static $listProps = [
1036            'list_exclude-groups',
1037            'list_include-groups',
1038        ];
1039        static $multiselectProps = [
1040            'allow-usergroups'
1041        ];
1042
1043        static $propPrereqs = [
1044            'not-centrally-blocked' => [
1045                'central-block-threshold'
1046            ],
1047            'list_edits-before' => [
1048                'list_edits-before-count',
1049                'list_edits-before-date',
1050            ],
1051            'list_edits-between' => [
1052                'list_edits-between-count',
1053                'list_edits-startdate',
1054                'list_edits-enddate',
1055            ]
1056        ];
1057
1058        if ( $formData['list_populate'] &&
1059            !$formData['list_edits-before'] &&
1060            !$formData['list_edits-between'] &&
1061            !$formData['list_exclude-groups'] &&
1062            !$formData['list_include-groups']
1063        ) {
1064            return Status::newFatal( 'securepoll-votereligibility-fail-nothing-to-process' );
1065        }
1066
1067        $properties = [];
1068        $deleteProperties = [];
1069
1070        // Unset any properties where the parent property is not checked and
1071        // mark them for deletion from the database
1072        foreach ( $propPrereqs as $parentProp => $childrenProps ) {
1073            if ( $formData[$parentProp] === '' || $formData[$parentProp] === false ) {
1074                foreach ( $childrenProps as $childProp ) {
1075                    $formData[ $childProp ] = '';
1076                    $deleteProperties[] = $childProp;
1077                }
1078            }
1079        }
1080
1081        foreach ( $props as $prop ) {
1082            if (
1083                $formData[$prop] !== '' &&
1084                $formData[$prop] !== false
1085            ) {
1086                $properties[$prop] = $formData[$prop];
1087            } else {
1088                $deleteProperties[] = $prop;
1089            }
1090        }
1091
1092        foreach ( $dateProps as $prop ) {
1093            if ( $formData[$prop] !== '' && $formData[$prop] !== [] ) {
1094                $dates = array_map(
1095                    static function ( $date ) {
1096                        $date = new DateTime( $date, new DateTimeZone( 'GMT' ) );
1097
1098                        return wfTimestamp( TS_MW, $date->format( 'YmdHis' ) );
1099                    },
1100                    (array)$formData[$prop]
1101                );
1102                $properties[$prop] = implode( '|', $dates );
1103            } else {
1104                $deleteProperties[] = $prop;
1105            }
1106        }
1107
1108        foreach ( $listProps as $prop ) {
1109            if ( $formData[$prop] ) {
1110                $names = array_map(
1111                    static function ( $entry ) {
1112                        return $entry['group'];
1113                    },
1114                    $formData[$prop]
1115                );
1116                sort( $names );
1117                $properties[$prop] = implode( '|', $names );
1118            } else {
1119                $deleteProperties[] = $prop;
1120            }
1121        }
1122
1123        foreach ( $multiselectProps as $prop ) {
1124            if ( $formData[$prop] ) {
1125                $properties[$prop] = implode( '|', $formData[$prop] );
1126            } else {
1127                $deleteProperties[] = $prop;
1128            }
1129        }
1130
1131        // De-dupe the $deleteProperties array
1132        $deleteProperties = array_unique( $deleteProperties );
1133
1134        $populate = !empty( $properties['list_populate'] );
1135        if ( $populate ) {
1136            $properties['need-list'] = 'need-list-' . $this->election->getId();
1137        }
1138
1139        $comment = $formData['comment'] ?? '';
1140
1141        $this->saveProperties( $properties, $deleteProperties, $comment );
1142
1143        if ( $populate ) {
1144            // Run pushJobsForElection() in a deferred update to give it outer transaction
1145            // scope, but keep it presend, so that any errors bubble up to the user.
1146            DeferredUpdates::addCallableUpdate(
1147                function () {
1148                    PopulateVoterListJob::pushJobsForElection( $this->election );
1149                },
1150                DeferredUpdates::PRESEND
1151            );
1152        }
1153
1154        return Status::newGood();
1155    }
1156
1157    private function executeEdit( string $which ) {
1158        $out = $this->specialPage->getOutput();
1159
1160        if ( !isset( self::$lists[$which] ) ) {
1161            $out->addWikiMsg( 'securepoll-votereligibility-invalid-list' );
1162
1163            return;
1164        }
1165        $property = self::$lists[$which];
1166        $name = $this->msg( "securepoll-votereligibility-$which" )->text();
1167
1168        if ( $which === 'voter' ) {
1169            $complete = $this->election->getProperty( 'list_complete-count', 0 );
1170            $total = $this->election->getProperty( 'list_total-count', 0 );
1171            if ( $complete !== $total ) {
1172                $out->addWikiMsg( 'securepoll-votereligibility-list-is-processing' );
1173
1174                return;
1175            }
1176        }
1177
1178        $out->addModuleStyles( 'ext.securepoll' );
1179        $out->setPageTitleMsg( $this->msg( 'securepoll-votereligibility-edit-title', $name ) );
1180
1181        $formItems = [];
1182
1183        $formItems['names'] = [
1184            'label-message' => 'securepoll-votereligibility-label-names',
1185            'type' => 'textarea',
1186            'rows' => 20,
1187            'default' => implode( "\n", $this->fetchList( $property ) ),
1188        ];
1189
1190        if ( $this->specialPage->getConfig()->get( 'SecurePollUseNamespace' ) ) {
1191            $formItems['comment'] = [
1192                'type' => 'text',
1193                'label-message' => 'securepoll-votereligibility-label-comment',
1194                'maxlength' => 250,
1195            ];
1196        }
1197
1198        $form = new HTMLForm(
1199            $formItems, $this->specialPage->getContext(), 'securepoll-votereligibility'
1200        );
1201        $form->addHeaderHtml(
1202            $this->msg( 'securepoll-votereligibility-edit-header' )->parseAsBlock()
1203        );
1204        $form->setDisplayFormat( 'div' );
1205        $form->setSubmitTextMsg( 'securepoll-votereligibility-edit-action' );
1206        $form->setSubmitCallback(
1207            function ( $formData, $form ) use ( $property ) {
1208                $this->saveList( $property, $formData['names'], $formData['comment'] ?? '' );
1209
1210                return Status::newGood();
1211            }
1212        );
1213        $result = $form->show();
1214
1215        if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
1216            $out->setPageTitleMsg( $this->msg( 'securepoll-votereligibility-saved' ) );
1217            $out->addWikiMsg( 'securepoll-votereligibility-saved-text' );
1218            $out->returnToMain(
1219                false,
1220                SpecialPage::getTitleFor(
1221                    'SecurePoll',
1222                    'votereligibility/' . $this->election->getId()
1223                )
1224            );
1225        }
1226    }
1227
1228    private function executeClear( string $which ) {
1229        $out = $this->specialPage->getOutput();
1230        $localWiki = WikiMap::getCurrentWikiId();
1231
1232        if ( !isset( self::$lists[$which] ) ) {
1233            $out->addWikiMsg( 'securepoll-votereligibility-invalid-list' );
1234
1235            return;
1236        }
1237        $property = self::$lists[$which];
1238        $name = $this->msg( "securepoll-votereligibility-$which" )->text();
1239
1240        $out = $this->specialPage->getOutput();
1241        $out->setPageTitleMsg( $this->msg( 'securepoll-votereligibility-clear-title', $name ) );
1242
1243        $wikis = $this->election->getProperty( 'wikis' );
1244        if ( $wikis ) {
1245            $wikis = explode( "\n", $wikis );
1246            $i = array_search( $localWiki, $wikis );
1247            if ( $i !== false ) {
1248                unset( $wikis[$i] );
1249            }
1250            array_unshift( $wikis, $localWiki );
1251        } else {
1252            $wikis = [ $localWiki ];
1253        }
1254
1255        foreach ( $wikis as $dbname ) {
1256            $dbw = $this->lbFactory->getAutoCommitPrimaryConnection( $dbname );
1257            $dbw->startAtomic( __METHOD__ );
1258
1259            $id = $dbw->newSelectQueryBuilder()
1260                ->select( 'el_entity' )
1261                ->from( 'securepoll_elections' )
1262                ->where( [
1263                    'el_title' => $this->election->title
1264                ] )
1265                ->caller( __METHOD__ )
1266                ->fetchField();
1267            if ( $id ) {
1268                $list = $dbw->newSelectQueryBuilder()
1269                    ->select( 'pr_value' )
1270                    ->from( 'securepoll_properties' )
1271                    ->where( [
1272                        'pr_entity' => $id,
1273                        'pr_key' => $property,
1274                    ] )
1275                    ->caller( __METHOD__ )
1276                    ->fetchField();
1277                if ( $list ) {
1278                    $dbw->newDeleteQueryBuilder()
1279                        ->deleteFrom( 'securepoll_lists' )
1280                        ->where( [ 'li_name' => $list ] )
1281                        ->caller( __METHOD__ )
1282                        ->execute();
1283                    $dbw->newDeleteQueryBuilder()
1284                        ->deleteFrom( 'securepoll_properties' )
1285                        ->where( [
1286                            'pr_entity' => $id,
1287                            'pr_key' => $property
1288                        ] )
1289                        ->caller( __METHOD__ )
1290                        ->execute();
1291                }
1292
1293                if ( $which === 'voter' ) {
1294                    $dbw->newDeleteQueryBuilder()
1295                        ->deleteFrom( 'securepoll_properties' )
1296                        ->where( [
1297                            'pr_entity' => $id,
1298                            'pr_key' => [
1299                                'list_populate',
1300                                'list_job-key',
1301                                'list_total-count',
1302                                'list_complete-count',
1303                                'list_job-key',
1304                            ],
1305                        ] )
1306                        ->caller( __METHOD__ )
1307                        ->execute();
1308                }
1309            }
1310
1311            $dbw->endAtomic( __METHOD__ );
1312        }
1313
1314        // Record this election to the SecurePoll namespace, if so configured.
1315        if ( $this->specialPage->getConfig()->get( 'SecurePollUseNamespace' ) ) {
1316            // Create a new context to bypass caching
1317            $context = new Context;
1318            $election = $context->getElection( $this->election->getId() );
1319
1320            [ $title, $content ] = SecurePollContentHandler::makeContentFromElection(
1321                $election
1322            );
1323            $wp = $this->wikiPageFactory->newFromTitle( $title );
1324            $wp->doUserEditContent(
1325                $content,
1326                $this->specialPage->getUser(),
1327                $this->msg( 'securepoll-votereligibility-cleared-comment', $name )->text()
1328            );
1329
1330            $title = $this->titleFactory->makeTitle( NS_SECUREPOLL, "{$election->getId()}/list/$property" );
1331            $wp = $this->wikiPageFactory->newFromTitle( $title );
1332            $wp->doUserEditContent(
1333                SecurePollContentHandler::makeContent( '[]', $title, 'SecurePoll' ),
1334                $this->specialPage->getUser(),
1335                $this->msg( 'securepoll-votereligibility-cleared-comment', $name )->text()
1336            );
1337        }
1338
1339        $out->setPageTitleMsg( $this->msg( 'securepoll-votereligibility-cleared' ) );
1340        $out->addWikiMsg( 'securepoll-votereligibility-cleared-text', $name );
1341        $out->returnToMain(
1342            false,
1343            SpecialPage::getTitleFor( 'SecurePoll', 'votereligibility/' . $this->election->getId() )
1344        );
1345    }
1346}