Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
1.54% covered (danger)
1.54%
13 / 846
6.25% covered (danger)
6.25%
1 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseFilterViewEdit
1.54% covered (danger)
1.54%
13 / 846
6.25% covered (danger)
6.25%
1 / 16
19119.51
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 show
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
342
 attemptSave
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
42
 showUnrecoverableError
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 buildFilterEditor
0.00% covered (danger)
0.00%
0 / 234
0.00% covered (danger)
0.00%
0 / 1
1482
 buildConsequenceEditor
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 buildConsequenceSelector
0.00% covered (danger)
0.00%
0 / 348
0.00% covered (danger)
0.00%
0 / 1
812
 getExistingSelector
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
72
 normalizeBlocks
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 getAbsoluteBlockDuration
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 loadFilterData
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 loadFromDatabase
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 loadRequest
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 loadImportRequest
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 loadActions
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
240
 exposeMessages
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\AbuseFilter\View;
4
5use HtmlArmor;
6use IContextSource;
7use IDBAccessObject;
8use LogicException;
9use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
10use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry;
11use MediaWiki\Extension\AbuseFilter\EditBox\EditBoxBuilderFactory;
12use MediaWiki\Extension\AbuseFilter\Filter\Filter;
13use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException;
14use MediaWiki\Extension\AbuseFilter\Filter\FilterVersionNotFoundException;
15use MediaWiki\Extension\AbuseFilter\Filter\MutableFilter;
16use MediaWiki\Extension\AbuseFilter\FilterImporter;
17use MediaWiki\Extension\AbuseFilter\FilterLookup;
18use MediaWiki\Extension\AbuseFilter\FilterProfiler;
19use MediaWiki\Extension\AbuseFilter\FilterStore;
20use MediaWiki\Extension\AbuseFilter\InvalidImportDataException;
21use MediaWiki\Extension\AbuseFilter\SpecsFormatter;
22use MediaWiki\Html\Html;
23use MediaWiki\Linker\Linker;
24use MediaWiki\Linker\LinkRenderer;
25use MediaWiki\MainConfigNames;
26use MediaWiki\Permissions\PermissionManager;
27use MediaWiki\SpecialPage\SpecialPage;
28use MediaWiki\Specials\SpecialBlock;
29use OOUI;
30use UnexpectedValueException;
31use Wikimedia\Rdbms\LBFactory;
32use Xml;
33
34class AbuseFilterViewEdit extends AbuseFilterView {
35    /**
36     * @var int|null The history ID of the current filter
37     */
38    private $historyID;
39    /** @var int|string */
40    private $filter;
41
42    /** @var LBFactory */
43    private $lbFactory;
44
45    /** @var PermissionManager */
46    private $permissionManager;
47
48    /** @var FilterProfiler */
49    private $filterProfiler;
50
51    /** @var FilterLookup */
52    private $filterLookup;
53
54    /** @var FilterImporter */
55    private $filterImporter;
56
57    /** @var FilterStore */
58    private $filterStore;
59
60    /** @var EditBoxBuilderFactory */
61    private $boxBuilderFactory;
62
63    /** @var ConsequencesRegistry */
64    private $consequencesRegistry;
65
66    /** @var SpecsFormatter */
67    private $specsFormatter;
68
69    /**
70     * @param LBFactory $lbFactory
71     * @param PermissionManager $permissionManager
72     * @param AbuseFilterPermissionManager $afPermManager
73     * @param FilterProfiler $filterProfiler
74     * @param FilterLookup $filterLookup
75     * @param FilterImporter $filterImporter
76     * @param FilterStore $filterStore
77     * @param EditBoxBuilderFactory $boxBuilderFactory
78     * @param ConsequencesRegistry $consequencesRegistry
79     * @param SpecsFormatter $specsFormatter
80     * @param IContextSource $context
81     * @param LinkRenderer $linkRenderer
82     * @param string $basePageName
83     * @param array $params
84     */
85    public function __construct(
86        LBFactory $lbFactory,
87        PermissionManager $permissionManager,
88        AbuseFilterPermissionManager $afPermManager,
89        FilterProfiler $filterProfiler,
90        FilterLookup $filterLookup,
91        FilterImporter $filterImporter,
92        FilterStore $filterStore,
93        EditBoxBuilderFactory $boxBuilderFactory,
94        ConsequencesRegistry $consequencesRegistry,
95        SpecsFormatter $specsFormatter,
96        IContextSource $context,
97        LinkRenderer $linkRenderer,
98        string $basePageName,
99        array $params
100    ) {
101        parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
102        $this->lbFactory = $lbFactory;
103        $this->permissionManager = $permissionManager;
104        $this->filterProfiler = $filterProfiler;
105        $this->filterLookup = $filterLookup;
106        $this->filterImporter = $filterImporter;
107        $this->filterStore = $filterStore;
108        $this->boxBuilderFactory = $boxBuilderFactory;
109        $this->consequencesRegistry = $consequencesRegistry;
110        $this->specsFormatter = $specsFormatter;
111        $this->specsFormatter->setMessageLocalizer( $this->getContext() );
112        $this->filter = $this->mParams['filter'];
113        $this->historyID = $this->mParams['history'] ?? null;
114    }
115
116    /**
117     * Shows the page
118     */
119    public function show() {
120        $out = $this->getOutput();
121        $out->enableOOUI();
122        $request = $this->getRequest();
123        $out->setPageTitleMsg( $this->msg( 'abusefilter-edit' ) );
124        $out->addHelpLink( 'Extension:AbuseFilter/Rules format' );
125
126        if ( !is_numeric( $this->filter ) && $this->filter !== null ) {
127            $this->showUnrecoverableError( 'abusefilter-edit-badfilter' );
128            return;
129        }
130        $filter = $this->filter ? (int)$this->filter : null;
131        $history_id = $this->historyID;
132        if ( $this->historyID ) {
133            $dbr = $this->lbFactory->getReplicaDatabase();
134            $lastID = (int)$dbr->selectField(
135                'abuse_filter_history',
136                'afh_id',
137                [
138                    'afh_filter' => $filter,
139                ],
140                __METHOD__,
141                [ 'ORDER BY' => 'afh_id DESC' ]
142            );
143            // change $history_id to null if it's current version id
144            if ( $lastID === $this->historyID ) {
145                $history_id = null;
146            }
147        }
148
149        // Add the default warning and disallow messages in a JS variable
150        $this->exposeMessages();
151
152        $canEdit = $this->afPermManager->canEdit( $this->getAuthority() );
153
154        if ( $filter === null && !$canEdit ) {
155            // Special case: Special:AbuseFilter/new is certainly not usable if the user cannot edit
156            $this->showUnrecoverableError( 'abusefilter-edit-notallowed' );
157            return;
158        }
159
160        $isImport = $request->wasPosted() && $request->getRawVal( 'wpImportText' ) !== null;
161
162        if ( !$isImport && $request->wasPosted() && $canEdit ) {
163            $this->attemptSave( $filter, $history_id );
164            return;
165        }
166
167        if ( $isImport ) {
168            $filterObj = $this->loadImportRequest();
169            if ( $filterObj === null ) {
170                $this->showUnrecoverableError( 'abusefilter-import-invalid-data' );
171                return;
172            }
173        } else {
174            // The request wasn't posted (i.e. just viewing the filter) or the user cannot edit
175            try {
176                $filterObj = $this->loadFromDatabase( $filter, $history_id );
177            } catch ( FilterNotFoundException $_ ) {
178                $filterObj = null;
179            }
180            if ( $filterObj === null || ( $history_id && (int)$filterObj->getID() !== $filter ) ) {
181                $this->showUnrecoverableError( 'abusefilter-edit-badfilter' );
182                return;
183            }
184        }
185
186        $this->buildFilterEditor( null, $filterObj, $filter, $history_id );
187    }
188
189    /**
190     * @param int|null $filter The filter ID or null for a new filter
191     * @param int|null $history_id The history ID of the filter, if applicable. Otherwise null
192     */
193    private function attemptSave( ?int $filter, $history_id ): void {
194        $out = $this->getOutput();
195        $request = $this->getRequest();
196        $user = $this->getUser();
197
198        [ $newFilter, $origFilter ] = $this->loadRequest( $filter );
199
200        $tokenFilter = $filter === null ? 'new' : (string)$filter;
201        $editToken = $request->getVal( 'wpEditToken' );
202        $tokenMatches = $user->matchEditToken(
203            $editToken, [ 'abusefilter', $tokenFilter ], $request );
204
205        if ( !$tokenMatches ) {
206            // Token invalid or expired while the page was open, warn to retry
207            $error = Html::warningBox( $this->msg( 'abusefilter-edit-token-not-match' )->parseAsBlock() );
208            $this->buildFilterEditor( $error, $newFilter, $filter, $history_id );
209            return;
210        }
211
212        $status = $this->filterStore->saveFilter( $user, $filter, $newFilter, $origFilter );
213
214        if ( !$status->isGood() ) {
215            $errors = $status->getErrors();
216            [ 'message' => $msg, 'params' => $params ] = $errors[0];
217            if ( $status->isOK() ) {
218                // Fixable error, show the editing interface
219                $error = Html::errorBox( $this->msg( $msg, $params )->parseAsBlock() );
220                $this->buildFilterEditor( $error, $newFilter, $filter, $history_id );
221            } else {
222                $this->showUnrecoverableError( $msg );
223            }
224        } elseif ( $status->getValue() === false ) {
225            // No change
226            $out->redirect( $this->getTitle()->getLocalURL() );
227        } else {
228            // Everything went fine!
229            [ $new_id, $history_id ] = $status->getValue();
230            $out->redirect(
231                $this->getTitle()->getLocalURL(
232                    [
233                        'result' => 'success',
234                        'changedfilter' => $new_id,
235                        'changeid' => $history_id,
236                    ]
237                )
238            );
239        }
240    }
241
242    /**
243     * @param string $msgKey
244     */
245    private function showUnrecoverableError( string $msgKey ): void {
246        $out = $this->getOutput();
247
248        $out->addHTML( Html::errorBox( $this->msg( $msgKey )->parseAsBlock() ) );
249        $href = $this->getTitle()->getFullURL();
250        $btn = new OOUI\ButtonWidget( [
251            'label' => $this->msg( 'abusefilter-return' )->text(),
252            'href' => $href
253        ] );
254        $out->addHTML( $btn );
255    }
256
257    /**
258     * Builds the full form for edit filters, adding it to the OutputPage. This method can be called in 5 different
259     * situations, for a total of 5 different data sources for $filterObj and $actions:
260     *  1 - View the result of importing a filter
261     *  2 - Create a new filter
262     *  3 - Load the current version of an existing filter
263     *  4 - Load an old version of an existing filter
264     *  5 - Show the user input again if saving fails after one of the steps above
265     *
266     * @param string|null $error An error message to show above the filter box (HTML).
267     * @param Filter $filterObj
268     * @param int|null $filter The filter ID, or null for a new filter
269     * @param int|null $history_id The history ID of the filter, if applicable. Otherwise null
270     */
271    private function buildFilterEditor(
272        $error,
273        Filter $filterObj,
274        ?int $filter,
275        $history_id
276    ) {
277        $out = $this->getOutput();
278        $out->addJsConfigVars( 'isFilterEditor', true );
279        $lang = $this->getLanguage();
280        $user = $this->getUser();
281        $actions = $filterObj->getActions();
282
283        $isCreatingNewFilter = $filter === null;
284        $out->addSubtitle( $this->msg(
285            $isCreatingNewFilter ? 'abusefilter-edit-subtitle-new' : 'abusefilter-edit-subtitle',
286            $isCreatingNewFilter ? $filter : $this->getLanguage()->formatNum( $filter ),
287            $history_id
288        )->parse() );
289
290        // We use filterHidden() to ensure that if a public filter is made private, the public
291        // revision is also hidden.
292        if (
293            ( $filterObj->isHidden() || (
294                $filter !== null && $this->filterLookup->getFilter( $filter, false )->isHidden() )
295            ) && !$this->afPermManager->canViewPrivateFilters( $user )
296        ) {
297            $out->addHTML( $this->msg( 'abusefilter-edit-denied' )->escaped() );
298            return;
299        }
300
301        if ( $isCreatingNewFilter ) {
302            $title = $this->msg( 'abusefilter-add' );
303        } elseif ( $this->afPermManager->canEditFilter( $user, $filterObj ) ) {
304            $title = $this->msg( 'abusefilter-edit-specific' )
305                ->numParams( $this->filter )
306                ->params( $filterObj->getName() );
307        } else {
308            $title = $this->msg( 'abusefilter-view-specific' )
309                ->numParams( $this->filter )
310                ->params( $filterObj->getName() );
311        }
312        $out->setPageTitleMsg( $title );
313
314        $readOnly = !$this->afPermManager->canEditFilter( $user, $filterObj );
315
316        if ( $history_id ) {
317            $oldWarningMessage = $readOnly
318                ? 'abusefilter-edit-oldwarning-view'
319                : 'abusefilter-edit-oldwarning';
320            $out->addWikiMsg( $oldWarningMessage, $history_id, $filter );
321        }
322
323        if ( $error !== null ) {
324            $out->addHTML( $error );
325        }
326
327        $fields = [];
328
329        $fields['abusefilter-edit-id'] =
330            $isCreatingNewFilter ?
331                $this->msg( 'abusefilter-edit-new' )->escaped() :
332                htmlspecialchars( $lang->formatNum( (string)$filter ) );
333        $fields['abusefilter-edit-description'] =
334            new OOUI\TextInputWidget( [
335                'name' => 'wpFilterDescription',
336                'id' => 'mw-abusefilter-edit-description-input',
337                'value' => $filterObj->getName(),
338                'readOnly' => $readOnly
339                ]
340            );
341
342        $validGroups = $this->getConfig()->get( 'AbuseFilterValidGroups' );
343        if ( count( $validGroups ) > 1 ) {
344            $groupSelector =
345                new OOUI\DropdownInputWidget( [
346                    'name' => 'wpFilterGroup',
347                    'id' => 'mw-abusefilter-edit-group-input',
348                    'value' => $filterObj->getGroup(),
349                    'disabled' => $readOnly
350                ] );
351
352            $options = [];
353            foreach ( $validGroups as $group ) {
354                $options += [ $this->specsFormatter->nameGroup( $group ) => $group ];
355            }
356
357            $options = Xml::listDropdownOptionsOoui( $options );
358            $groupSelector->setOptions( $options );
359
360            $fields['abusefilter-edit-group'] = $groupSelector;
361        }
362
363        // Hit count display
364        if ( $filterObj->getHitCount() !== null && $this->afPermManager->canSeeLogDetails( $user ) ) {
365            $count_display = $this->msg( 'abusefilter-hitcount' )
366                ->numParams( $filterObj->getHitCount() )->text();
367            $hitCount = $this->linkRenderer->makeKnownLink(
368                SpecialPage::getTitleFor( 'AbuseLog' ),
369                $count_display,
370                [],
371                [ 'wpSearchFilter' => $filterObj->getID() ]
372            );
373
374            $fields['abusefilter-edit-hitcount'] = $hitCount;
375        }
376
377        if ( $filter !== null && $filterObj->isEnabled() ) {
378            // Statistics
379            [
380                'count' => $totalCount,
381                'matches' => $matchesCount,
382                'total-time' => $curTotalTime,
383                'total-cond' => $curTotalConds,
384            ] = $this->filterProfiler->getFilterProfile( $filter );
385
386            if ( $totalCount > 0 ) {
387                $matchesPercent = round( 100 * $matchesCount / $totalCount, 2 );
388                $avgTime = round( $curTotalTime / $totalCount, 2 );
389                $avgCond = round( $curTotalConds / $totalCount, 1 );
390
391                $fields['abusefilter-edit-status-label'] = $this->msg( 'abusefilter-edit-status' )
392                    ->numParams( $totalCount, $matchesCount, $matchesPercent, $avgTime, $avgCond )
393                    ->parse();
394            }
395        }
396
397        $boxBuilder = $this->boxBuilderFactory->newEditBoxBuilder( $this, $user, $out );
398
399        $fields['abusefilter-edit-rules'] = $boxBuilder->buildEditBox(
400            $filterObj->getRules(),
401            true
402        );
403        $fields['abusefilter-edit-notes'] =
404            new OOUI\MultilineTextInputWidget( [
405                'name' => 'wpFilterNotes',
406                'value' => $filterObj->getComments() . "\n",
407                'rows' => 15,
408                'readOnly' => $readOnly,
409                'id' => 'mw-abusefilter-notes-editor'
410            ] );
411
412        // Build checkboxes
413        $checkboxes = [ 'hidden', 'enabled', 'deleted' ];
414        $flags = '';
415
416        if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
417            $checkboxes[] = 'global';
418        }
419
420        if ( $filterObj->isThrottled() ) {
421            $throttledActionNames = array_intersect(
422                $filterObj->getActionsNames(),
423                $this->consequencesRegistry->getDangerousActionNames()
424            );
425
426            if ( $throttledActionNames ) {
427                $throttledActionsLocalized = [];
428                foreach ( $throttledActionNames as $actionName ) {
429                    $throttledActionsLocalized[] = $this->specsFormatter->getActionMessage( $actionName )->text();
430                }
431
432                $throttledMsg = $this->msg( 'abusefilter-edit-throttled-warning' )
433                    ->plaintextParams( $lang->commaList( $throttledActionsLocalized ) )
434                    ->params( count( $throttledActionsLocalized ) )
435                    ->parseAsBlock();
436            } else {
437                $throttledMsg = $this->msg( 'abusefilter-edit-throttled-warning-no-actions' )
438                    ->parseAsBlock();
439            }
440            $flags .= Html::warningBox( $throttledMsg );
441        }
442
443        foreach ( $checkboxes as $checkboxId ) {
444            // Messages that can be used here:
445            // * abusefilter-edit-enabled
446            // * abusefilter-edit-deleted
447            // * abusefilter-edit-hidden
448            // * abusefilter-edit-global
449            $message = "abusefilter-edit-$checkboxId";
450            // isEnabled(), isDeleted(), isHidden(), isGlobal()
451            $method = 'is' . ucfirst( $checkboxId );
452            $postVar = 'wpFilter' . ucfirst( $checkboxId );
453
454            $checkboxAttribs = [
455                'name' => $postVar,
456                'id' => $postVar,
457                'selected' => $filterObj->$method(),
458                'disabled' => $readOnly
459            ];
460            $labelAttribs = [
461                'label' => $this->msg( $message )->text(),
462                'align' => 'inline',
463            ];
464
465            if ( $checkboxId === 'global' && !$this->afPermManager->canEditGlobal( $user ) ) {
466                $checkboxAttribs['disabled'] = 'disabled';
467            }
468
469            // Set readonly on deleted if the filter isn't disabled
470            if ( $checkboxId === 'deleted' && $filterObj->isEnabled() ) {
471                $checkboxAttribs['disabled'] = 'disabled';
472            }
473
474            // Add infusable where needed
475            if ( $checkboxId === 'deleted' || $checkboxId === 'enabled' ) {
476                $checkboxAttribs['infusable'] = true;
477                if ( $checkboxId === 'deleted' ) {
478                    $labelAttribs['id'] = $postVar . 'Label';
479                    $labelAttribs['infusable'] = true;
480                }
481            }
482
483            $checkbox =
484                new OOUI\FieldLayout(
485                    new OOUI\CheckboxInputWidget( $checkboxAttribs ),
486                    $labelAttribs
487                );
488            $flags .= $checkbox;
489        }
490
491        $fields['abusefilter-edit-flags'] = $flags;
492
493        if ( $filter !== null ) {
494            $tools = '';
495            if ( $this->afPermManager->canRevertFilterActions( $user ) ) {
496                $tools .= Xml::tags(
497                    'p', null,
498                    $this->linkRenderer->makeLink(
499                        $this->getTitle( "revert/$filter" ),
500                        new HtmlArmor( $this->msg( 'abusefilter-edit-revert' )->parse() )
501                    )
502                );
503            }
504
505            if ( $this->afPermManager->canUseTestTools( $user ) ) {
506                // Test link
507                $tools .= Xml::tags(
508                    'p', null,
509                    $this->linkRenderer->makeLink(
510                        $this->getTitle( "test/$filter" ),
511                        new HtmlArmor( $this->msg( 'abusefilter-edit-test-link' )->parse() )
512                    )
513                );
514            }
515            // Last modification details
516            $userLink =
517                Linker::userLink( $filterObj->getUserID(), $filterObj->getUserName() ) .
518                Linker::userToolLinks( $filterObj->getUserID(), $filterObj->getUserName() );
519            $fields['abusefilter-edit-lastmod'] =
520                $this->msg( 'abusefilter-edit-lastmod-text' )
521                ->rawParams(
522                    $this->getLinkToLatestDiff(
523                        $filter,
524                        $lang->userTimeAndDate( $filterObj->getTimestamp(), $user )
525                    ),
526                    $userLink,
527                    $this->getLinkToLatestDiff(
528                        $filter,
529                        $lang->userDate( $filterObj->getTimestamp(), $user )
530                    ),
531                    $this->getLinkToLatestDiff(
532                        $filter,
533                        $lang->userTime( $filterObj->getTimestamp(), $user )
534                    )
535                )->params(
536                    wfEscapeWikiText( $filterObj->getUserName() )
537                )->parse();
538            $history_display = new HtmlArmor( $this->msg( 'abusefilter-edit-viewhistory' )->parse() );
539            $fields['abusefilter-edit-history'] =
540                $this->linkRenderer->makeKnownLink( $this->getTitle( 'history/' . $filter ), $history_display );
541
542            $exportText = $this->filterImporter->encodeData( $filterObj, $actions );
543            $tools .= Xml::tags( 'a', [ 'href' => '#', 'id' => 'mw-abusefilter-export-link' ],
544                $this->msg( 'abusefilter-edit-export' )->parse() );
545            $tools .=
546                new OOUI\MultilineTextInputWidget( [
547                    'id' => 'mw-abusefilter-export',
548                    'readOnly' => true,
549                    'value' => $exportText,
550                    'rows' => 10
551                ] );
552
553            $fields['abusefilter-edit-tools'] = $tools;
554        }
555
556        $form = Xml::buildForm( $fields );
557        $form = Xml::fieldset( $this->msg( 'abusefilter-edit-main' )->text(), $form );
558        $form .= Xml::fieldset(
559            $this->msg( 'abusefilter-edit-consequences' )->text(),
560            $this->buildConsequenceEditor( $filterObj, $actions )
561        );
562
563        $urlFilter = $filter === null ? 'new' : (string)$filter;
564        if ( !$readOnly ) {
565            $form .=
566                new OOUI\ButtonInputWidget( [
567                    'type' => 'submit',
568                    'label' => $this->msg( 'abusefilter-edit-save' )->text(),
569                    'useInputTag' => true,
570                    'accesskey' => 's',
571                    'flags' => [ 'progressive', 'primary' ]
572                ] );
573            $form .= Html::hidden(
574                'wpEditToken',
575                $user->getEditToken( [ 'abusefilter', $urlFilter ] )
576            );
577        }
578
579        $form = Xml::tags( 'form',
580            [
581                'action' => $this->getTitle( $urlFilter )->getFullURL(),
582                'method' => 'post',
583                'id' => 'mw-abusefilter-editing-form'
584            ],
585            $form
586        );
587
588        $out->addHTML( $form );
589
590        if ( $history_id ) {
591            // @phan-suppress-next-line PhanPossiblyUndeclaredVariable,PhanTypeMismatchArgumentNullable
592            $out->addWikiMsg( $oldWarningMessage, $history_id, $filter );
593        }
594    }
595
596    /**
597     * Builds the "actions" editor for a given filter.
598     * @param Filter $filterObj
599     * @param array[] $actions Array of rows from the abuse_filter_action table
600     *  corresponding to the filter object
601     * @return string HTML text for an action editor.
602     */
603    private function buildConsequenceEditor( Filter $filterObj, array $actions ) {
604        $enabledActions = $this->consequencesRegistry->getAllEnabledActionNames();
605
606        $setActions = [];
607        foreach ( $enabledActions as $action ) {
608            $setActions[$action] = array_key_exists( $action, $actions );
609        }
610
611        $output = '';
612
613        foreach ( $enabledActions as $action ) {
614            $params = $actions[$action] ?? null;
615            $output .= $this->buildConsequenceSelector(
616                $action, $setActions[$action], $filterObj, $params );
617        }
618
619        return $output;
620    }
621
622    /**
623     * @param string $action The action to build an editor for
624     * @param bool $set Whether or not the action is activated
625     * @param Filter $filterObj
626     * @param string[]|null $parameters Action parameters. Null iff $set is false.
627     * @return string|\OOUI\FieldLayout
628     */
629    private function buildConsequenceSelector( $action, $set, $filterObj, ?array $parameters ) {
630        $config = $this->getConfig();
631        $user = $this->getUser();
632        $actions = $this->consequencesRegistry->getAllEnabledActionNames();
633        if ( !in_array( $action, $actions, true ) ) {
634            return '';
635        }
636
637        $readOnly = !$this->afPermManager->canEditFilter( $user, $filterObj );
638
639        switch ( $action ) {
640            case 'throttle':
641                // Throttling is only available via object caching
642                if ( $config->get( MainConfigNames::MainCacheType ) === CACHE_NONE ) {
643                    return '';
644                }
645                $throttleSettings =
646                    new OOUI\FieldLayout(
647                        new OOUI\CheckboxInputWidget( [
648                            'name' => 'wpFilterActionThrottle',
649                            'id' => 'mw-abusefilter-action-checkbox-throttle',
650                            'selected' => $set,
651                            'classes' => [ 'mw-abusefilter-action-checkbox' ],
652                            'disabled' => $readOnly
653                        ]
654                        ),
655                        [
656                            'label' => $this->msg( 'abusefilter-edit-action-throttle' )->text(),
657                            'align' => 'inline'
658                        ]
659                    );
660                $throttleFields = [];
661
662                if ( $set ) {
663                    // @phan-suppress-next-line PhanTypeArraySuspiciousNullable $parameters is array here
664                    [ $throttleCount, $throttlePeriod ] = explode( ',', $parameters[1], 2 );
665
666                    $throttleGroups = array_slice( $parameters, 2 );
667                } else {
668                    $throttleCount = 3;
669                    $throttlePeriod = 60;
670
671                    $throttleGroups = [ 'user' ];
672                }
673
674                $throttleFields[] =
675                    new OOUI\FieldLayout(
676                        new OOUI\TextInputWidget( [
677                            'type' => 'number',
678                            'name' => 'wpFilterThrottleCount',
679                            'value' => $throttleCount,
680                            'readOnly' => $readOnly
681                            ]
682                        ),
683                        [
684                            'label' => $this->msg( 'abusefilter-edit-throttle-count' )->text()
685                        ]
686                    );
687                $throttleFields[] =
688                    new OOUI\FieldLayout(
689                        new OOUI\TextInputWidget( [
690                            'type' => 'number',
691                            'name' => 'wpFilterThrottlePeriod',
692                            'value' => $throttlePeriod,
693                            'readOnly' => $readOnly
694                            ]
695                        ),
696                        [
697                            'label' => $this->msg( 'abusefilter-edit-throttle-period' )->text()
698                        ]
699                    );
700
701                $groupsHelpLink = Html::element(
702                    'a',
703                    [
704                        'href' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/' .
705                            'Extension:AbuseFilter/Actions#Throttling',
706                        'target' => '_blank'
707                    ],
708                    $this->msg( 'abusefilter-edit-throttle-groups-help-text' )->text()
709                );
710                $groupsHelp = $this->msg( 'abusefilter-edit-throttle-groups-help' )
711                        ->rawParams( $groupsHelpLink )->escaped();
712                $hiddenGroups =
713                    new OOUI\FieldLayout(
714                        new OOUI\MultilineTextInputWidget( [
715                            'name' => 'wpFilterThrottleGroups',
716                            'value' => implode( "\n", $throttleGroups ),
717                            'rows' => 5,
718                            'placeholder' => $this->msg( 'abusefilter-edit-throttle-hidden-placeholder' )->text(),
719                            'infusable' => true,
720                            'id' => 'mw-abusefilter-hidden-throttle-field',
721                            'readOnly' => $readOnly
722                        ]
723                        ),
724                        [
725                            'label' => new OOUI\HtmlSnippet(
726                                $this->msg( 'abusefilter-edit-throttle-groups' )->parse()
727                            ),
728                            'align' => 'top',
729                            'id' => 'mw-abusefilter-hidden-throttle',
730                            'help' => new OOUI\HtmlSnippet( $groupsHelp ),
731                            'helpInline' => true
732                        ]
733                    );
734
735                $throttleFields[] = $hiddenGroups;
736
737                $throttleConfig = [
738                    'values' => $throttleGroups,
739                    'label' => $this->msg( 'abusefilter-edit-throttle-groups' )->parse(),
740                    'disabled' => $readOnly,
741                    'help' => $groupsHelp
742                ];
743                $this->getOutput()->addJsConfigVars( 'throttleConfig', $throttleConfig );
744
745                $throttleSettings .=
746                    Xml::tags(
747                        'div',
748                        [ 'id' => 'mw-abusefilter-throttle-parameters' ],
749                        new OOUI\FieldsetLayout( [ 'items' => $throttleFields ] )
750                    );
751                return $throttleSettings;
752            case 'disallow':
753            case 'warn':
754                $output = '';
755                $formName = $action === 'warn' ? 'wpFilterActionWarn' : 'wpFilterActionDisallow';
756                $checkbox =
757                    new OOUI\FieldLayout(
758                        new OOUI\CheckboxInputWidget( [
759                            'name' => $formName,
760                            // mw-abusefilter-action-checkbox-warn, mw-abusefilter-action-checkbox-disallow
761                            'id' => "mw-abusefilter-action-checkbox-$action",
762                            'selected' => $set,
763                            'classes' => [ 'mw-abusefilter-action-checkbox' ],
764                            'disabled' => $readOnly
765                        ]
766                        ),
767                        [
768                            // abusefilter-edit-action-warn, abusefilter-edit-action-disallow
769                            'label' => $this->msg( "abusefilter-edit-action-$action" )->text(),
770                            'align' => 'inline'
771                        ]
772                    );
773                $output .= $checkbox;
774                $defaultWarnMsg = $config->get( 'AbuseFilterDefaultWarningMessage' );
775                $defaultDisallowMsg = $config->get( 'AbuseFilterDefaultDisallowMessage' );
776
777                if ( $set && isset( $parameters[0] ) ) {
778                    $msg = $parameters[0];
779                } elseif (
780                    ( $action === 'warn' && isset( $defaultWarnMsg[$filterObj->getGroup()] ) ) ||
781                    ( $action === 'disallow' && isset( $defaultDisallowMsg[$filterObj->getGroup()] ) )
782                ) {
783                    $msg = $action === 'warn' ? $defaultWarnMsg[$filterObj->getGroup()] :
784                        $defaultDisallowMsg[$filterObj->getGroup()];
785                } else {
786                    $msg = $action === 'warn' ? 'abusefilter-warning' : 'abusefilter-disallowed';
787                }
788
789                $fields = [];
790                $fields[] =
791                    $this->getExistingSelector( $msg, $readOnly, $action );
792                $otherFieldName = $action === 'warn' ? 'wpFilterWarnMessageOther'
793                    : 'wpFilterDisallowMessageOther';
794
795                $fields[] =
796                    new OOUI\FieldLayout(
797                        new OOUI\TextInputWidget( [
798                            'name' => $otherFieldName,
799                            'value' => $msg,
800                            // mw-abusefilter-warn-message-other, mw-abusefilter-disallow-message-other
801                            'id' => "mw-abusefilter-$action-message-other",
802                            'infusable' => true,
803                            'readOnly' => $readOnly
804                            ]
805                        ),
806                        [
807                            'label' => new OOUI\HtmlSnippet(
808                                // abusefilter-edit-warn-other-label, abusefilter-edit-disallow-other-label
809                                $this->msg( "abusefilter-edit-$action-other-label" )->parse()
810                            )
811                        ]
812                    );
813
814                $previewButton =
815                    new OOUI\ButtonInputWidget( [
816                        // abusefilter-edit-warn-preview, abusefilter-edit-disallow-preview
817                        'label' => $this->msg( "abusefilter-edit-$action-preview" )->text(),
818                        // mw-abusefilter-warn-preview-button, mw-abusefilter-disallow-preview-button
819                        'id' => "mw-abusefilter-$action-preview-button",
820                        'infusable' => true,
821                        'flags' => 'progressive'
822                        ]
823                    );
824
825                $buttonGroup = $previewButton;
826                if ( $this->permissionManager->userHasRight( $user, 'editinterface' ) ) {
827                    $editButton =
828                        new OOUI\ButtonInputWidget( [
829                            // abusefilter-edit-warn-edit, abusefilter-edit-disallow-edit
830                            'label' => $this->msg( "abusefilter-edit-$action-edit" )->text(),
831                            // mw-abusefilter-warn-edit-button, mw-abusefilter-disallow-edit-button
832                            'id' => "mw-abusefilter-$action-edit-button"
833                            ]
834                        );
835                    $buttonGroup =
836                        new OOUI\Widget( [
837                            'content' =>
838                                new OOUI\HorizontalLayout( [
839                                    'items' => [ $previewButton, $editButton ],
840                                    'classes' => [
841                                        'mw-abusefilter-preview-buttons',
842                                        'mw-abusefilter-javascript-tools'
843                                    ]
844                                ] )
845                        ] );
846                }
847                $previewHolder = Xml::tags(
848                    'div',
849                    [
850                        // mw-abusefilter-warn-preview, mw-abusefilter-disallow-preview
851                        'id' => "mw-abusefilter-$action-preview",
852                        'style' => 'display:none'
853                    ],
854                    ''
855                );
856                $fields[] = $buttonGroup;
857                $output .=
858                    Xml::tags(
859                        'div',
860                        // mw-abusefilter-warn-parameters, mw-abusefilter-disallow-parameters
861                        [ 'id' => "mw-abusefilter-$action-parameters" ],
862                        new OOUI\FieldsetLayout( [ 'items' => $fields ] )
863                    ) . $previewHolder;
864
865                return $output;
866            case 'tag':
867                $tags = $set ? $parameters : [];
868                '@phan-var string[] $parameters';
869                $output = '';
870
871                $checkbox =
872                    new OOUI\FieldLayout(
873                        new OOUI\CheckboxInputWidget( [
874                            'name' => 'wpFilterActionTag',
875                            'id' => 'mw-abusefilter-action-checkbox-tag',
876                            'selected' => $set,
877                            'classes' => [ 'mw-abusefilter-action-checkbox' ],
878                            'disabled' => $readOnly
879                        ]
880                        ),
881                        [
882                            'label' => $this->msg( 'abusefilter-edit-action-tag' )->text(),
883                            'align' => 'inline'
884                        ]
885                    );
886                $output .= $checkbox;
887
888                $tagConfig = [
889                    'values' => $tags,
890                    'label' => $this->msg( 'abusefilter-edit-tag-tag' )->parse(),
891                    'disabled' => $readOnly
892                ];
893                $this->getOutput()->addJsConfigVars( 'tagConfig', $tagConfig );
894
895                $hiddenTags =
896                    new OOUI\FieldLayout(
897                        new OOUI\MultilineTextInputWidget( [
898                            'name' => 'wpFilterTags',
899                            'value' => implode( ',', $tags ),
900                            'rows' => 5,
901                            'placeholder' => $this->msg( 'abusefilter-edit-tag-hidden-placeholder' )->text(),
902                            'infusable' => true,
903                            'id' => 'mw-abusefilter-hidden-tag-field',
904                            'readOnly' => $readOnly
905                        ]
906                        ),
907                        [
908                            'label' => new OOUI\HtmlSnippet(
909                                $this->msg( 'abusefilter-edit-tag-tag' )->parse()
910                            ),
911                            'align' => 'top',
912                            'id' => 'mw-abusefilter-hidden-tag'
913                        ]
914                    );
915                $output .=
916                    Xml::tags( 'div',
917                        [ 'id' => 'mw-abusefilter-tag-parameters' ],
918                        $hiddenTags
919                    );
920                return $output;
921            case 'block':
922                if ( $set && count( $parameters ) === 3 ) {
923                    // Both blocktalk and custom block durations available
924                    [ $blockTalk, $defaultAnonDuration, $defaultUserDuration ] = $parameters;
925                } else {
926                    if ( $set && count( $parameters ) === 1 ) {
927                        // Only blocktalk available
928                        $blockTalk = $parameters[0];
929                    }
930                    $defaultAnonDuration = $config->get( 'AbuseFilterAnonBlockDuration' ) ??
931                        $config->get( 'AbuseFilterBlockDuration' );
932                    $defaultUserDuration = $config->get( 'AbuseFilterBlockDuration' );
933                }
934                $suggestedBlocks = SpecialBlock::getSuggestedDurations( null, false );
935                $suggestedBlocks = self::normalizeBlocks( $suggestedBlocks );
936
937                $output = '';
938                $checkbox =
939                    new OOUI\FieldLayout(
940                        new OOUI\CheckboxInputWidget( [
941                            'name' => 'wpFilterActionBlock',
942                            'id' => 'mw-abusefilter-action-checkbox-block',
943                            'selected' => $set,
944                            'classes' => [ 'mw-abusefilter-action-checkbox' ],
945                            'disabled' => $readOnly
946                        ]
947                        ),
948                        [
949                            'label' => $this->msg( 'abusefilter-edit-action-block' )->text(),
950                            'align' => 'inline'
951                        ]
952                    );
953                $output .= $checkbox;
954
955                $suggestedBlocks = Xml::listDropdownOptionsOoui( $suggestedBlocks );
956
957                $anonDuration =
958                    new OOUI\DropdownInputWidget( [
959                        'name' => 'wpBlockAnonDuration',
960                        'options' => $suggestedBlocks,
961                        'value' => $defaultAnonDuration,
962                        'disabled' => $readOnly
963                    ] );
964
965                $userDuration =
966                    new OOUI\DropdownInputWidget( [
967                        'name' => 'wpBlockUserDuration',
968                        'options' => $suggestedBlocks,
969                        'value' => $defaultUserDuration,
970                        'disabled' => $readOnly
971                    ] );
972
973                $blockOptions = [];
974                if ( $config->get( MainConfigNames::BlockAllowsUTEdit ) === true ) {
975                    $talkCheckbox =
976                        new OOUI\FieldLayout(
977                            new OOUI\CheckboxInputWidget( [
978                                'name' => 'wpFilterBlockTalk',
979                                'id' => 'mw-abusefilter-action-checkbox-blocktalk',
980                                'selected' => isset( $blockTalk ) && $blockTalk === 'blocktalk',
981                                'classes' => [ 'mw-abusefilter-action-checkbox' ],
982                                'disabled' => $readOnly
983                            ]
984                            ),
985                            [
986                                'label' => $this->msg( 'abusefilter-edit-action-blocktalk' )->text(),
987                                'align' => 'left'
988                            ]
989                        );
990
991                    $blockOptions[] = $talkCheckbox;
992                }
993                $blockOptions[] =
994                    new OOUI\FieldLayout(
995                        $anonDuration,
996                        [
997                            'label' => $this->msg( 'abusefilter-edit-block-anon-durations' )->text()
998                        ]
999                    );
1000                $blockOptions[] =
1001                    new OOUI\FieldLayout(
1002                        $userDuration,
1003                        [
1004                            'label' => $this->msg( 'abusefilter-edit-block-user-durations' )->text()
1005                        ]
1006                    );
1007
1008                $output .= Xml::tags(
1009                        'div',
1010                        [ 'id' => 'mw-abusefilter-block-parameters' ],
1011                        new OOUI\FieldsetLayout( [ 'items' => $blockOptions ] )
1012                    );
1013
1014                return $output;
1015
1016            default:
1017                // Give grep a chance to find the usages:
1018                // abusefilter-edit-action-disallow,
1019                // abusefilter-edit-action-blockautopromote,
1020                // abusefilter-edit-action-degroup,
1021                // abusefilter-edit-action-rangeblock,
1022                $message = 'abusefilter-edit-action-' . $action;
1023                $form_field = 'wpFilterAction' . ucfirst( $action );
1024                $status = $set;
1025
1026                $thisAction =
1027                    new OOUI\FieldLayout(
1028                        new OOUI\CheckboxInputWidget( [
1029                            'name' => $form_field,
1030                            'id' => "mw-abusefilter-action-checkbox-$action",
1031                            'selected' => $status,
1032                            'classes' => [ 'mw-abusefilter-action-checkbox' ],
1033                            'disabled' => $readOnly
1034                        ]
1035                        ),
1036                        [
1037                            'label' => $this->msg( $message )->text(),
1038                            'align' => 'inline'
1039                        ]
1040                    );
1041                return $thisAction;
1042        }
1043    }
1044
1045    /**
1046     * @param string $warnMsg
1047     * @param bool $readOnly
1048     * @param string $action
1049     * @return \OOUI\FieldLayout
1050     */
1051    public function getExistingSelector( $warnMsg, $readOnly = false, $action = 'warn' ) {
1052        if ( $action === 'warn' ) {
1053            $action = 'warning';
1054            $formId = 'warn';
1055            $inputName = 'wpFilterWarnMessage';
1056        } elseif ( $action === 'disallow' ) {
1057            $action = 'disallowed';
1058            $formId = 'disallow';
1059            $inputName = 'wpFilterDisallowMessage';
1060        } else {
1061            throw new UnexpectedValueException( "Unexpected action value $action" );
1062        }
1063
1064        $existingSelector =
1065            new OOUI\DropdownInputWidget( [
1066                'name' => $inputName,
1067                // mw-abusefilter-warn-message-existing, mw-abusefilter-disallow-message-existing
1068                'id' => "mw-abusefilter-$formId-message-existing",
1069                // abusefilter-warning, abusefilter-disallowed
1070                'value' => $warnMsg === "abusefilter-$action" ? "abusefilter-$action" : 'other',
1071                'infusable' => true
1072            ] );
1073
1074        // abusefilter-warning, abusefilter-disallowed
1075        $options = [ "abusefilter-$action" => "abusefilter-$action" ];
1076
1077        if ( $readOnly ) {
1078            $existingSelector->setDisabled( true );
1079        } else {
1080            // Find other messages.
1081            $dbr = $this->lbFactory->getReplicaDatabase();
1082            $pageTitlePrefix = "Abusefilter-$action";
1083            $titles = $dbr->selectFieldValues(
1084                'page',
1085                'page_title',
1086                [
1087                    'page_namespace' => 8,
1088                    'page_title LIKE ' . $dbr->addQuotes( $pageTitlePrefix . '%' )
1089                ],
1090                __METHOD__
1091            );
1092
1093            $lang = $this->getLanguage();
1094            foreach ( $titles as $title ) {
1095                if ( $lang->lcfirst( $title ) === $lang->lcfirst( $warnMsg ) ) {
1096                    $existingSelector->setValue( $lang->lcfirst( $warnMsg ) );
1097                }
1098
1099                if ( $title !== "Abusefilter-$action" ) {
1100                    $options[ $lang->lcfirst( $title ) ] = $lang->lcfirst( $title );
1101                }
1102            }
1103        }
1104
1105        // abusefilter-edit-warn-other, abusefilter-edit-disallow-other
1106        $options[ $this->msg( "abusefilter-edit-$formId-other" )->text() ] = 'other';
1107
1108        $options = Xml::listDropdownOptionsOoui( $options );
1109        $existingSelector->setOptions( $options );
1110
1111        $existingSelector =
1112            new OOUI\FieldLayout(
1113                $existingSelector,
1114                [
1115                    // abusefilter-edit-warn-message, abusefilter-edit-disallow-message
1116                    'label' => $this->msg( "abusefilter-edit-$formId-message" )->text()
1117                ]
1118            );
1119
1120        return $existingSelector;
1121    }
1122
1123    /**
1124     * @todo Maybe we should also check if global values belong to $durations
1125     * and determine the right point to add them if missing.
1126     *
1127     * @param string[] $durations
1128     * @return string[]
1129     */
1130    private static function normalizeBlocks( array $durations ) {
1131        global $wgAbuseFilterBlockDuration, $wgAbuseFilterAnonBlockDuration;
1132        // We need to have same values since it may happen that ipblocklist
1133        // and one (or both) of the global variables use different wording
1134        // for the same duration. In such case, when setting the default of
1135        // the dropdowns it would fail.
1136        $anonDuration = self::getAbsoluteBlockDuration( $wgAbuseFilterAnonBlockDuration ??
1137            $wgAbuseFilterBlockDuration );
1138        $userDuration = self::getAbsoluteBlockDuration( $wgAbuseFilterBlockDuration );
1139        foreach ( $durations as &$duration ) {
1140            $currentDuration = self::getAbsoluteBlockDuration( $duration );
1141
1142            if ( $duration !== $wgAbuseFilterBlockDuration &&
1143                $currentDuration === $userDuration ) {
1144                $duration = $wgAbuseFilterBlockDuration;
1145
1146            } elseif ( $duration !== $wgAbuseFilterAnonBlockDuration &&
1147                $currentDuration === $anonDuration ) {
1148                $duration = $wgAbuseFilterAnonBlockDuration;
1149            }
1150        }
1151
1152        return $durations;
1153    }
1154
1155    /**
1156     * Converts a string duration to an absolute timestamp, i.e. unrelated to the current
1157     * time, taking into account infinity durations as well. The second parameter of
1158     * strtotime is set to 0 in order to convert the duration in seconds (instead of
1159     * a timestamp), thus making it unaffected by the execution time of the code.
1160     *
1161     * @param string $duration
1162     * @return string|int
1163     */
1164    private static function getAbsoluteBlockDuration( $duration ) {
1165        if ( wfIsInfinity( $duration ) ) {
1166            return 'infinity';
1167        }
1168        return strtotime( $duration, 0 );
1169    }
1170
1171    /**
1172     * Loads filter data from the database by ID.
1173     * @param int|null $id The filter's ID number, or null for a new filter
1174     * @return Filter
1175     * @throws FilterNotFoundException
1176     */
1177    private function loadFilterData( ?int $id ): Filter {
1178        if ( $id === null ) {
1179            return MutableFilter::newDefault();
1180        }
1181
1182        $flags = $this->getRequest()->wasPosted()
1183            // Load from primary database to avoid unintended reversions where there's replication lag.
1184            ? IDBAccessObject::READ_LATEST
1185            : IDBAccessObject::READ_NORMAL;
1186
1187        return $this->filterLookup->getFilter( $id, false, $flags );
1188    }
1189
1190    /**
1191     * Load filter data to show in the edit view from the DB.
1192     * @param int|null $filter The filter ID being requested or null for a new filter
1193     * @param int|null $history_id If any, the history ID being requested.
1194     * @return Filter|null Null if the filter does not exist.
1195     */
1196    private function loadFromDatabase( ?int $filter, $history_id = null ): ?Filter {
1197        if ( $history_id ) {
1198            try {
1199                return $this->filterLookup->getFilterVersion( $history_id );
1200            } catch ( FilterVersionNotFoundException $_ ) {
1201                return null;
1202            }
1203        } else {
1204            return $this->loadFilterData( $filter );
1205        }
1206    }
1207
1208    /**
1209     * Load data from the HTTP request. Used for saving the filter, and when the token doesn't match
1210     * @param int|null $filter
1211     * @return Filter[]
1212     */
1213    private function loadRequest( ?int $filter ): array {
1214        $request = $this->getRequest();
1215        if ( !$request->wasPosted() ) {
1216            // Sanity
1217            throw new LogicException( __METHOD__ . ' called without the request being POSTed.' );
1218        }
1219
1220        $origFilter = $this->loadFilterData( $filter );
1221
1222        /** @var MutableFilter $newFilter */
1223        $newFilter = $origFilter instanceof MutableFilter
1224            ? clone $origFilter
1225            : MutableFilter::newFromParentFilter( $origFilter );
1226
1227        if ( $filter !== null ) {
1228            // Unchangeable values
1229            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
1230            $newFilter->setThrottled( $origFilter->isThrottled() );
1231            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
1232            $newFilter->setHitCount( $origFilter->getHitCount() );
1233            // These are needed if the save fails and the filter is not new
1234            $newFilter->setID( $origFilter->getID() );
1235            $newFilter->setUserID( $origFilter->getUserID() );
1236            $newFilter->setUserName( $origFilter->getUserName() );
1237            $newFilter->setTimestamp( $origFilter->getTimestamp() );
1238        }
1239
1240        $newFilter->setName( trim( $request->getVal( 'wpFilterDescription' ) ) );
1241        $newFilter->setRules( trim( $request->getVal( 'wpFilterRules' ) ) );
1242        $newFilter->setComments( trim( $request->getVal( 'wpFilterNotes' ) ) );
1243
1244        $newFilter->setGroup( $request->getVal( 'wpFilterGroup', 'default' ) );
1245
1246        $newFilter->setDeleted( $request->getCheck( 'wpFilterDeleted' ) );
1247        $newFilter->setEnabled( $request->getCheck( 'wpFilterEnabled' ) );
1248        $newFilter->setHidden( $request->getCheck( 'wpFilterHidden' ) );
1249        $newFilter->setGlobal( $request->getCheck( 'wpFilterGlobal' )
1250            && $this->getConfig()->get( 'AbuseFilterIsCentral' ) );
1251
1252        $actions = $this->loadActions();
1253
1254        $newFilter->setActions( $actions );
1255
1256        return [ $newFilter, $origFilter ];
1257    }
1258
1259    /**
1260     * @return Filter|null
1261     */
1262    private function loadImportRequest(): ?Filter {
1263        $request = $this->getRequest();
1264        if ( !$request->wasPosted() ) {
1265            // Sanity
1266            throw new LogicException( __METHOD__ . ' called without the request being POSTed.' );
1267        }
1268
1269        try {
1270            $filter = $this->filterImporter->decodeData( $request->getVal( 'wpImportText' ) );
1271        } catch ( InvalidImportDataException $_ ) {
1272            return null;
1273        }
1274
1275        return $filter;
1276    }
1277
1278    /**
1279     * @return array[]
1280     */
1281    private function loadActions(): array {
1282        $request = $this->getRequest();
1283        $allActions = $this->consequencesRegistry->getAllEnabledActionNames();
1284        $actions = [];
1285        foreach ( $allActions as $action ) {
1286            // Check if it's set
1287            $enabled = $request->getCheck( 'wpFilterAction' . ucfirst( $action ) );
1288
1289            if ( $enabled ) {
1290                $parameters = [];
1291
1292                if ( $action === 'throttle' ) {
1293                    // We need to load the parameters
1294                    $throttleCount = $request->getIntOrNull( 'wpFilterThrottleCount' );
1295                    $throttlePeriod = $request->getIntOrNull( 'wpFilterThrottlePeriod' );
1296                    // First explode with \n, which is the delimiter used in the textarea
1297                    $rawGroups = explode( "\n", $request->getText( 'wpFilterThrottleGroups' ) );
1298                    // Trim any space, both as an actual group and inside subgroups
1299                    $throttleGroups = [];
1300                    foreach ( $rawGroups as $group ) {
1301                        if ( strpos( $group, ',' ) !== false ) {
1302                            $subGroups = explode( ',', $group );
1303                            $throttleGroups[] = implode( ',', array_map( 'trim', $subGroups ) );
1304                        } elseif ( trim( $group ) !== '' ) {
1305                            $throttleGroups[] = trim( $group );
1306                        }
1307                    }
1308
1309                    $parameters[0] = $this->filter;
1310                    $parameters[1] = "$throttleCount,$throttlePeriod";
1311                    $parameters = array_merge( $parameters, $throttleGroups );
1312                } elseif ( $action === 'warn' ) {
1313                    $specMsg = $request->getVal( 'wpFilterWarnMessage' );
1314
1315                    if ( $specMsg === 'other' ) {
1316                        $specMsg = $request->getVal( 'wpFilterWarnMessageOther' );
1317                    }
1318
1319                    $parameters[0] = $specMsg;
1320                } elseif ( $action === 'block' ) {
1321                    // TODO: Should save a boolean
1322                    $parameters[0] = $request->getCheck( 'wpFilterBlockTalk' ) ?
1323                        'blocktalk' : 'noTalkBlockSet';
1324                    $parameters[1] = $request->getVal( 'wpBlockAnonDuration' );
1325                    $parameters[2] = $request->getVal( 'wpBlockUserDuration' );
1326                } elseif ( $action === 'disallow' ) {
1327                    $specMsg = $request->getVal( 'wpFilterDisallowMessage' );
1328
1329                    if ( $specMsg === 'other' ) {
1330                        $specMsg = $request->getVal( 'wpFilterDisallowMessageOther' );
1331                    }
1332
1333                    $parameters[0] = $specMsg;
1334                } elseif ( $action === 'tag' ) {
1335                    $parameters = explode( ',', trim( $request->getText( 'wpFilterTags' ) ) );
1336                    if ( $parameters === [ '' ] ) {
1337                        // Since it's not possible to manually add an empty tag, this only happens
1338                        // if the form is submitted without touching the tag input field.
1339                        // We pass an empty array so that the widget won't show an empty tag in the topbar
1340                        $parameters = [];
1341                    }
1342                }
1343
1344                $actions[$action] = $parameters;
1345            }
1346        }
1347        return $actions;
1348    }
1349
1350    /**
1351     * Exports the default warning and disallow messages to a JS variable
1352     */
1353    private function exposeMessages() {
1354        $this->getOutput()->addJsConfigVars(
1355            'wgAbuseFilterDefaultWarningMessage',
1356            $this->getConfig()->get( 'AbuseFilterDefaultWarningMessage' )
1357        );
1358        $this->getOutput()->addJsConfigVars(
1359            'wgAbuseFilterDefaultDisallowMessage',
1360            $this->getConfig()->get( 'AbuseFilterDefaultDisallowMessage' )
1361        );
1362    }
1363}