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