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