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