Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
1.50% covered (danger)
1.50%
13 / 867
6.25% covered (danger)
6.25%
1 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbuseFilterViewEdit
1.50% covered (danger)
1.50%
13 / 867
6.25% covered (danger)
6.25%
1 / 16
21081.40
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 / 30
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 / 256
0.00% covered (danger)
0.00%
0 / 1
2070
 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 / 27
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 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\Permissions\PermissionManager;
26use MediaWiki\SpecialPage\SpecialPage;
27use MediaWiki\Xml\Xml;
28use OOUI;
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 PermissionManager */
48    private $permissionManager;
49
50    /** @var FilterProfiler */
51    private $filterProfiler;
52
53    /** @var FilterLookup */
54    private $filterLookup;
55
56    /** @var FilterImporter */
57    private $filterImporter;
58
59    /** @var FilterStore */
60    private $filterStore;
61
62    /** @var EditBoxBuilderFactory */
63    private $boxBuilderFactory;
64
65    /** @var ConsequencesRegistry */
66    private $consequencesRegistry;
67
68    /** @var SpecsFormatter */
69    private $specsFormatter;
70
71    /**
72     * @param LBFactory $lbFactory
73     * @param PermissionManager $permissionManager
74     * @param AbuseFilterPermissionManager $afPermManager
75     * @param FilterProfiler $filterProfiler
76     * @param FilterLookup $filterLookup
77     * @param FilterImporter $filterImporter
78     * @param FilterStore $filterStore
79     * @param EditBoxBuilderFactory $boxBuilderFactory
80     * @param ConsequencesRegistry $consequencesRegistry
81     * @param SpecsFormatter $specsFormatter
82     * @param IContextSource $context
83     * @param LinkRenderer $linkRenderer
84     * @param string $basePageName
85     * @param array $params
86     */
87    public function __construct(
88        LBFactory $lbFactory,
89        PermissionManager $permissionManager,
90        AbuseFilterPermissionManager $afPermManager,
91        FilterProfiler $filterProfiler,
92        FilterLookup $filterLookup,
93        FilterImporter $filterImporter,
94        FilterStore $filterStore,
95        EditBoxBuilderFactory $boxBuilderFactory,
96        ConsequencesRegistry $consequencesRegistry,
97        SpecsFormatter $specsFormatter,
98        IContextSource $context,
99        LinkRenderer $linkRenderer,
100        string $basePageName,
101        array $params
102    ) {
103        parent::__construct( $afPermManager, $context, $linkRenderer, $basePageName, $params );
104        $this->lbFactory = $lbFactory;
105        $this->permissionManager = $permissionManager;
106        $this->filterProfiler = $filterProfiler;
107        $this->filterLookup = $filterLookup;
108        $this->filterImporter = $filterImporter;
109        $this->filterStore = $filterStore;
110        $this->boxBuilderFactory = $boxBuilderFactory;
111        $this->consequencesRegistry = $consequencesRegistry;
112        $this->specsFormatter = $specsFormatter;
113        $this->specsFormatter->setMessageLocalizer( $this->getContext() );
114        $this->filter = $this->mParams['filter'];
115        $this->historyID = $this->mParams['history'] ?? null;
116    }
117
118    /**
119     * Shows the page
120     */
121    public function show() {
122        $out = $this->getOutput();
123        $out->enableOOUI();
124        $request = $this->getRequest();
125        $out->setPageTitleMsg( $this->msg( 'abusefilter-edit' ) );
126        $out->addHelpLink( 'Extension:AbuseFilter/Rules format' );
127
128        if ( !is_numeric( $this->filter ) && $this->filter !== null ) {
129            $this->showUnrecoverableError( 'abusefilter-edit-badfilter' );
130            return;
131        }
132        $filter = $this->filter ? (int)$this->filter : null;
133        $history_id = $this->historyID;
134        if ( $this->historyID ) {
135            $dbr = $this->lbFactory->getReplicaDatabase();
136            $lastID = (int)$dbr->newSelectQueryBuilder()
137                ->select( 'afh_id' )
138                ->from( 'abuse_filter_history' )
139                ->where( [
140                    'afh_filter' => $filter,
141                ] )
142                ->orderBy( 'afh_id', SelectQueryBuilder::SORT_DESC )
143                ->caller( __METHOD__ )
144                ->fetchField();
145            // change $history_id to null if it's current version id
146            if ( $lastID === $this->historyID ) {
147                $history_id = null;
148            }
149        }
150
151        // Add the default warning and disallow messages in a JS variable
152        $this->exposeMessages();
153
154        $canEdit = $this->afPermManager->canEdit( $this->getAuthority() );
155
156        if ( $filter === null && !$canEdit ) {
157            // Special case: Special:AbuseFilter/new is certainly not usable if the user cannot edit
158            $this->showUnrecoverableError( 'abusefilter-edit-notallowed' );
159            return;
160        }
161
162        $isImport = $request->wasPosted() && $request->getRawVal( 'wpImportText' ) !== null;
163
164        if ( !$isImport && $request->wasPosted() && $canEdit ) {
165            $this->attemptSave( $filter, $history_id );
166            return;
167        }
168
169        if ( $isImport ) {
170            $filterObj = $this->loadImportRequest();
171            if ( $filterObj === null ) {
172                $this->showUnrecoverableError( 'abusefilter-import-invalid-data' );
173                return;
174            }
175        } else {
176            // The request wasn't posted (i.e. just viewing the filter) or the user cannot edit
177            try {
178                $filterObj = $this->loadFromDatabase( $filter, $history_id );
179            } catch ( FilterNotFoundException $_ ) {
180                $filterObj = null;
181            }
182            if ( $filterObj === null || ( $history_id && (int)$filterObj->getID() !== $filter ) ) {
183                $this->showUnrecoverableError( 'abusefilter-edit-badfilter' );
184                return;
185            }
186        }
187
188        $this->buildFilterEditor( null, $filterObj, $filter, $history_id );
189    }
190
191    /**
192     * @param int|null $filter The filter ID or null for a new filter
193     * @param int|null $history_id The history ID of the filter, if applicable. Otherwise null
194     */
195    private function attemptSave( ?int $filter, $history_id ): void {
196        $out = $this->getOutput();
197        $request = $this->getRequest();
198        $user = $this->getUser();
199
200        [ $newFilter, $origFilter ] = $this->loadRequest( $filter );
201
202        $tokenFilter = $filter === null ? 'new' : (string)$filter;
203        $editToken = $request->getVal( 'wpEditToken' );
204        $tokenMatches = $this->getCsrfTokenSet()->matchToken( $editToken, [ 'abusefilter', $tokenFilter ] );
205
206        if ( !$tokenMatches ) {
207            // Token invalid or expired while the page was open, warn to retry
208            $error = Html::warningBox( $this->msg( 'abusefilter-edit-token-not-match' )->parseAsBlock() );
209            $this->buildFilterEditor( $error, $newFilter, $filter, $history_id );
210            return;
211        }
212
213        $status = $this->filterStore->saveFilter( $user, $filter, $newFilter, $origFilter );
214
215        if ( !$status->isGood() ) {
216            $msg = $status->getMessages()[0];
217            if ( $status->isOK() ) {
218                // Fixable error, show the editing interface
219                $error = Html::errorBox( $this->msg( $msg )->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|\Wikimedia\Message\MessageSpecifier $msg
244     */
245    private function showUnrecoverableError( $msg ): void {
246        $out = $this->getOutput();
247
248        $out->addHTML( Html::errorBox( $this->msg( $msg )->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        // Grab the current hidden flag from the DB, in case we're editing an older, public revision of a filter that is
291        // currently hidden, so that we can also hide that public revision.
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        // Filters that use protected variables should always be hidden from public view
302        if (
303            (
304                $filterObj->isProtected() ||
305                ( $filter !== null && $this->filterLookup->getFilter( $filter, false )->isProtected() )
306            ) &&
307            !$this->afPermManager->canViewProtectedVariables( $user )
308        ) {
309            $out->addHTML( $this->msg( 'abusefilter-edit-denied-protected-vars' )->escaped() );
310            return;
311        }
312
313        if ( $isCreatingNewFilter ) {
314            $title = $this->msg( 'abusefilter-add' );
315        } elseif ( $this->afPermManager->canEditFilter( $user, $filterObj ) ) {
316            $title = $this->msg( 'abusefilter-edit-specific' )
317                ->numParams( $this->filter )
318                ->params( $filterObj->getName() );
319        } else {
320            $title = $this->msg( 'abusefilter-view-specific' )
321                ->numParams( $this->filter )
322                ->params( $filterObj->getName() );
323        }
324        $out->setPageTitleMsg( $title );
325
326        $readOnly = !$this->afPermManager->canEditFilter( $user, $filterObj );
327
328        if ( $history_id ) {
329            $oldWarningMessage = $readOnly
330                ? 'abusefilter-edit-oldwarning-view'
331                : 'abusefilter-edit-oldwarning';
332            $out->addWikiMsg( $oldWarningMessage, $history_id, $filter );
333        }
334
335        if ( $error !== null ) {
336            $out->addHTML( $error );
337        }
338
339        $fields = [];
340
341        $fields['abusefilter-edit-id'] =
342            $isCreatingNewFilter ?
343                $this->msg( 'abusefilter-edit-new' )->escaped() :
344                htmlspecialchars( $lang->formatNum( (string)$filter ) );
345        $fields['abusefilter-edit-description'] =
346            new OOUI\TextInputWidget( [
347                'name' => 'wpFilterDescription',
348                'id' => 'mw-abusefilter-edit-description-input',
349                'value' => $filterObj->getName(),
350                'readOnly' => $readOnly
351                ]
352            );
353
354        $validGroups = $this->getConfig()->get( 'AbuseFilterValidGroups' );
355        if ( count( $validGroups ) > 1 ) {
356            $groupSelector =
357                new OOUI\DropdownInputWidget( [
358                    'name' => 'wpFilterGroup',
359                    'id' => 'mw-abusefilter-edit-group-input',
360                    'value' => $filterObj->getGroup(),
361                    'disabled' => $readOnly
362                ] );
363
364            $options = [];
365            foreach ( $validGroups as $group ) {
366                $options += [ $this->specsFormatter->nameGroup( $group ) => $group ];
367            }
368
369            $options = Html::listDropdownOptionsOoui( $options );
370            $groupSelector->setOptions( $options );
371
372            $fields['abusefilter-edit-group'] = $groupSelector;
373        }
374
375        // Hit count display
376        $hitCount = $filterObj->getHitCount();
377        if ( $hitCount !== null && $this->afPermManager->canSeeLogDetails( $user ) ) {
378            $count_display = $this->msg( 'abusefilter-hitcount' )
379                ->numParams( $hitCount )->text();
380            $hitCount = $this->linkRenderer->makeKnownLink(
381                SpecialPage::getTitleFor( 'AbuseLog' ),
382                $count_display,
383                [],
384                [ 'wpSearchFilter' => $filterObj->getID() ]
385            );
386
387            $fields['abusefilter-edit-hitcount'] = $hitCount;
388        }
389
390        if ( $filter !== null && $filterObj->isEnabled() ) {
391            // Statistics
392            [
393                'count' => $totalCount,
394                'matches' => $matchesCount,
395                'total-time' => $curTotalTime,
396                'total-cond' => $curTotalConds,
397            ] = $this->filterProfiler->getFilterProfile( $filter );
398
399            if ( $totalCount > 0 ) {
400                $matchesPercent = round( 100 * $matchesCount / $totalCount, 2 );
401                $avgTime = round( $curTotalTime / $totalCount, 2 );
402                $avgCond = round( $curTotalConds / $totalCount, 1 );
403
404                $fields['abusefilter-edit-status-label'] = $this->msg( 'abusefilter-edit-status' )
405                    ->numParams( $totalCount, $matchesCount, $matchesPercent, $avgTime, $avgCond )
406                    ->parse();
407            }
408        }
409
410        $boxBuilder = $this->boxBuilderFactory->newEditBoxBuilder( $this, $user, $out );
411
412        $fields['abusefilter-edit-rules'] = $boxBuilder->buildEditBox(
413            $filterObj->getRules(),
414            true
415        );
416        $fields['abusefilter-edit-notes'] =
417            new OOUI\MultilineTextInputWidget( [
418                'name' => 'wpFilterNotes',
419                'value' => $filterObj->getComments() . "\n",
420                'rows' => 15,
421                'readOnly' => $readOnly,
422                'id' => 'mw-abusefilter-notes-editor'
423            ] );
424
425        // Build checkboxes
426        $checkboxes = [ 'hidden', 'enabled', 'protected', 'deleted' ];
427        $flags = '';
428
429        if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
430            $checkboxes[] = 'global';
431        }
432
433        if ( $filterObj->isThrottled() ) {
434            $throttledActionNames = array_intersect(
435                $filterObj->getActionsNames(),
436                $this->consequencesRegistry->getDangerousActionNames()
437            );
438
439            if ( $throttledActionNames ) {
440                $throttledActionsLocalized = [];
441                foreach ( $throttledActionNames as $actionName ) {
442                    $throttledActionsLocalized[] = $this->specsFormatter->getActionMessage( $actionName )->text();
443                }
444
445                $throttledMsg = $this->msg( 'abusefilter-edit-throttled-warning' )
446                    ->plaintextParams( $lang->commaList( $throttledActionsLocalized ) )
447                    ->params( count( $throttledActionsLocalized ) )
448                    ->parseAsBlock();
449            } else {
450                $throttledMsg = $this->msg( 'abusefilter-edit-throttled-warning-no-actions' )
451                    ->parseAsBlock();
452            }
453            $flags .= Html::warningBox( $throttledMsg );
454        }
455
456        foreach ( $checkboxes as $checkboxId ) {
457            // Messages that can be used here:
458            // * abusefilter-edit-enabled
459            // * abusefilter-edit-deleted
460            // * abusefilter-edit-hidden
461            // * abusefilter-edit-protected
462            // * abusefilter-edit-global
463            $message = "abusefilter-edit-$checkboxId";
464            // isEnabled(), isDeleted(), isHidden(), isProtected(), isGlobal()
465            $method = 'is' . ucfirst( $checkboxId );
466            // wpFilterEnabled, wpFilterDeleted, wpFilterHidden, wpFilterProtected, wpFilterGlobal
467            $postVar = 'wpFilter' . ucfirst( $checkboxId );
468
469            $checkboxAttribs = [
470                'name' => $postVar,
471                'id' => $postVar,
472                'selected' => $filterObj->$method(),
473                'disabled' => $readOnly
474            ];
475            $labelAttribs = [
476                'label' => $this->msg( $message )->text(),
477                'align' => 'inline',
478            ];
479
480            if ( $checkboxId === 'global' && !$this->afPermManager->canEditGlobal( $user ) ) {
481                $checkboxAttribs['disabled'] = 'disabled';
482            }
483
484            if ( $checkboxId == 'protected' ) {
485                if ( !$this->afPermManager->canViewProtectedVariables( $user ) ) {
486                    $checkboxAttribs['classes'] = [ 'oo-ui-element-hidden' ];
487                    $labelAttribs['classes'] = [ 'oo-ui-element-hidden' ];
488                } elseif ( $filterObj->isProtected() ) {
489                    $checkboxAttribs['disabled'] = true;
490                    $labelAttribs['label'] = $this->msg(
491                        'abusefilter-edit-protected-variable-already-protected'
492                    )->text();
493                } else {
494                    $labelAttribs['label'] = new OOUI\HtmlSnippet(
495                        $this->msg( $message )->parse()
496                    );
497                    $labelAttribs['help'] = $this->msg( 'abusefilter-edit-protected-help-message' )->text();
498                    $labelAttribs['helpInline'] = true;
499                }
500            }
501
502            // Set readonly on deleted if the filter isn't disabled
503            if ( $checkboxId === 'deleted' && $filterObj->isEnabled() ) {
504                $checkboxAttribs['disabled'] = 'disabled';
505            }
506
507            // Add infusable where needed
508            if ( $checkboxId === 'deleted' || $checkboxId === 'enabled' ) {
509                $checkboxAttribs['infusable'] = true;
510                if ( $checkboxId === 'deleted' ) {
511                    // wpFilterDeletedLabel
512                    $labelAttribs['id'] = $postVar . 'Label';
513                    $labelAttribs['infusable'] = true;
514                }
515            }
516
517            $checkbox =
518                new OOUI\FieldLayout(
519                    new OOUI\CheckboxInputWidget( $checkboxAttribs ),
520                    $labelAttribs
521                );
522            $flags .= $checkbox;
523        }
524
525        $fields['abusefilter-edit-flags'] = $flags;
526
527        if ( $filter !== null ) {
528            $tools = '';
529            if ( $this->afPermManager->canRevertFilterActions( $user ) ) {
530                $tools .= Html::rawElement(
531                    'p', [],
532                    $this->linkRenderer->makeLink(
533                        $this->getTitle( "revert/$filter" ),
534                        new HtmlArmor( $this->msg( 'abusefilter-edit-revert' )->parse() )
535                    )
536                );
537            }
538
539            if ( $this->afPermManager->canUseTestTools( $user ) ) {
540                // Test link
541                $tools .= Html::rawElement(
542                    'p', [],
543                    $this->linkRenderer->makeLink(
544                        $this->getTitle( "test/$filter" ),
545                        new HtmlArmor( $this->msg( 'abusefilter-edit-test-link' )->parse() )
546                    )
547                );
548            }
549            // Last modification details
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        $user = $this->getUser();
669        $actions = $this->consequencesRegistry->getAllEnabledActionNames();
670        if ( !in_array( $action, $actions, true ) ) {
671            return '';
672        }
673
674        $readOnly = !$this->afPermManager->canEditFilter( $user, $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 ( $this->permissionManager->userHasRight( $user, '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}