lbFactory = $lbFactory; $this->permissionManager = $permissionManager; $this->filterProfiler = $filterProfiler; $this->filterLookup = $filterLookup; $this->filterImporter = $filterImporter; $this->filterStore = $filterStore; $this->boxBuilderFactory = $boxBuilderFactory; $this->consequencesRegistry = $consequencesRegistry; $this->specsFormatter = $specsFormatter; $this->specsFormatter->setMessageLocalizer( $this->getContext() ); $this->filter = $this->mParams['filter']; $this->historyID = $this->mParams['history'] ?? null; } /** * Shows the page */ public function show() { $out = $this->getOutput(); $out->enableOOUI(); $request = $this->getRequest(); $out->setPageTitleMsg( $this->msg( 'abusefilter-edit' ) ); $out->addHelpLink( 'Extension:AbuseFilter/Rules format' ); if ( !is_numeric( $this->filter ) && $this->filter !== null ) { $this->showUnrecoverableError( 'abusefilter-edit-badfilter' ); return; } $filter = $this->filter ? (int)$this->filter : null; $history_id = $this->historyID; if ( $this->historyID ) { $dbr = $this->lbFactory->getReplicaDatabase(); $lastID = (int)$dbr->newSelectQueryBuilder() ->select( 'afh_id' ) ->from( 'abuse_filter_history' ) ->where( [ 'afh_filter' => $filter, ] ) ->orderBy( 'afh_id', SelectQueryBuilder::SORT_DESC ) ->caller( __METHOD__ ) ->fetchField(); // change $history_id to null if it's current version id if ( $lastID === $this->historyID ) { $history_id = null; } } // Add the default warning and disallow messages in a JS variable $this->exposeMessages(); $canEdit = $this->afPermManager->canEdit( $this->getAuthority() ); if ( $filter === null && !$canEdit ) { // Special case: Special:AbuseFilter/new is certainly not usable if the user cannot edit $this->showUnrecoverableError( 'abusefilter-edit-notallowed' ); return; } $isImport = $request->wasPosted() && $request->getRawVal( 'wpImportText' ) !== null; if ( !$isImport && $request->wasPosted() && $canEdit ) { $this->attemptSave( $filter, $history_id ); return; } if ( $isImport ) { $filterObj = $this->loadImportRequest(); if ( $filterObj === null ) { $this->showUnrecoverableError( 'abusefilter-import-invalid-data' ); return; } } else { // The request wasn't posted (i.e. just viewing the filter) or the user cannot edit try { $filterObj = $this->loadFromDatabase( $filter, $history_id ); } catch ( FilterNotFoundException $_ ) { $filterObj = null; } if ( $filterObj === null || ( $history_id && (int)$filterObj->getID() !== $filter ) ) { $this->showUnrecoverableError( 'abusefilter-edit-badfilter' ); return; } } $this->buildFilterEditor( null, $filterObj, $filter, $history_id ); } /** * @param int|null $filter The filter ID or null for a new filter * @param int|null $history_id The history ID of the filter, if applicable. Otherwise null */ private function attemptSave( ?int $filter, $history_id ): void { $out = $this->getOutput(); $request = $this->getRequest(); $user = $this->getUser(); [ $newFilter, $origFilter ] = $this->loadRequest( $filter ); $tokenFilter = $filter === null ? 'new' : (string)$filter; $editToken = $request->getVal( 'wpEditToken' ); $tokenMatches = $this->getCsrfTokenSet()->matchToken( $editToken, [ 'abusefilter', $tokenFilter ] ); if ( !$tokenMatches ) { // Token invalid or expired while the page was open, warn to retry $error = Html::warningBox( $this->msg( 'abusefilter-edit-token-not-match' )->parseAsBlock() ); $this->buildFilterEditor( $error, $newFilter, $filter, $history_id ); return; } $status = $this->filterStore->saveFilter( $user, $filter, $newFilter, $origFilter ); if ( !$status->isGood() ) { $msg = $status->getMessages()[0]; if ( $status->isOK() ) { // Fixable error, show the editing interface $error = Html::errorBox( $this->msg( $msg )->parseAsBlock() ); $this->buildFilterEditor( $error, $newFilter, $filter, $history_id ); } else { $this->showUnrecoverableError( $msg ); } } elseif ( $status->getValue() === false ) { // No change $out->redirect( $this->getTitle()->getLocalURL() ); } else { // Everything went fine! [ $new_id, $history_id ] = $status->getValue(); $out->redirect( $this->getTitle()->getLocalURL( [ 'result' => 'success', 'changedfilter' => $new_id, 'changeid' => $history_id, ] ) ); } } /** * @param string|\MessageSpecifier $msg */ private function showUnrecoverableError( $msg ): void { $out = $this->getOutput(); $out->addHTML( Html::errorBox( $this->msg( $msg )->parseAsBlock() ) ); $href = $this->getTitle()->getFullURL(); $btn = new OOUI\ButtonWidget( [ 'label' => $this->msg( 'abusefilter-return' )->text(), 'href' => $href ] ); $out->addHTML( $btn ); } /** * Builds the full form for edit filters, adding it to the OutputPage. This method can be called in 5 different * situations, for a total of 5 different data sources for $filterObj and $actions: * 1 - View the result of importing a filter * 2 - Create a new filter * 3 - Load the current version of an existing filter * 4 - Load an old version of an existing filter * 5 - Show the user input again if saving fails after one of the steps above * * @param string|null $error An error message to show above the filter box (HTML). * @param Filter $filterObj * @param int|null $filter The filter ID, or null for a new filter * @param int|null $history_id The history ID of the filter, if applicable. Otherwise null */ private function buildFilterEditor( $error, Filter $filterObj, ?int $filter, $history_id ) { $out = $this->getOutput(); $out->addJsConfigVars( 'isFilterEditor', true ); $lang = $this->getLanguage(); $user = $this->getUser(); $actions = $filterObj->getActions(); $isCreatingNewFilter = $filter === null; $out->addSubtitle( $this->msg( $isCreatingNewFilter ? 'abusefilter-edit-subtitle-new' : 'abusefilter-edit-subtitle', $isCreatingNewFilter ? $filter : $this->getLanguage()->formatNum( $filter ), $history_id )->parse() ); // We use filterHidden() to ensure that if a public filter is made private, the public // revision is also hidden. if ( ( $filterObj->isHidden() || ( $filter !== null && $this->filterLookup->getFilter( $filter, false )->isHidden() ) ) && !$this->afPermManager->canViewPrivateFilters( $user ) ) { $out->addHTML( $this->msg( 'abusefilter-edit-denied' )->escaped() ); return; } // Filters that use protected variables should always be hidden from public view if ( $filterObj->isProtected() && !$this->afPermManager->canViewProtectedVariables( $user ) ) { $out->addHTML( $this->msg( 'abusefilter-edit-denied-protected-vars' )->escaped() ); return; } if ( $isCreatingNewFilter ) { $title = $this->msg( 'abusefilter-add' ); } elseif ( $this->afPermManager->canEditFilter( $user, $filterObj ) ) { $title = $this->msg( 'abusefilter-edit-specific' ) ->numParams( $this->filter ) ->params( $filterObj->getName() ); } else { $title = $this->msg( 'abusefilter-view-specific' ) ->numParams( $this->filter ) ->params( $filterObj->getName() ); } $out->setPageTitleMsg( $title ); $readOnly = !$this->afPermManager->canEditFilter( $user, $filterObj ); if ( $history_id ) { $oldWarningMessage = $readOnly ? 'abusefilter-edit-oldwarning-view' : 'abusefilter-edit-oldwarning'; $out->addWikiMsg( $oldWarningMessage, $history_id, $filter ); } if ( $error !== null ) { $out->addHTML( $error ); } $fields = []; $fields['abusefilter-edit-id'] = $isCreatingNewFilter ? $this->msg( 'abusefilter-edit-new' )->escaped() : htmlspecialchars( $lang->formatNum( (string)$filter ) ); $fields['abusefilter-edit-description'] = new OOUI\TextInputWidget( [ 'name' => 'wpFilterDescription', 'id' => 'mw-abusefilter-edit-description-input', 'value' => $filterObj->getName(), 'readOnly' => $readOnly ] ); $validGroups = $this->getConfig()->get( 'AbuseFilterValidGroups' ); if ( count( $validGroups ) > 1 ) { $groupSelector = new OOUI\DropdownInputWidget( [ 'name' => 'wpFilterGroup', 'id' => 'mw-abusefilter-edit-group-input', 'value' => $filterObj->getGroup(), 'disabled' => $readOnly ] ); $options = []; foreach ( $validGroups as $group ) { $options += [ $this->specsFormatter->nameGroup( $group ) => $group ]; } $options = Html::listDropdownOptionsOoui( $options ); $groupSelector->setOptions( $options ); $fields['abusefilter-edit-group'] = $groupSelector; } // Hit count display if ( $filterObj->getHitCount() !== null && $this->afPermManager->canSeeLogDetails( $user ) ) { $count_display = $this->msg( 'abusefilter-hitcount' ) ->numParams( $filterObj->getHitCount() )->text(); $hitCount = $this->linkRenderer->makeKnownLink( SpecialPage::getTitleFor( 'AbuseLog' ), $count_display, [], [ 'wpSearchFilter' => $filterObj->getID() ] ); $fields['abusefilter-edit-hitcount'] = $hitCount; } if ( $filter !== null && $filterObj->isEnabled() ) { // Statistics [ 'count' => $totalCount, 'matches' => $matchesCount, 'total-time' => $curTotalTime, 'total-cond' => $curTotalConds, ] = $this->filterProfiler->getFilterProfile( $filter ); if ( $totalCount > 0 ) { $matchesPercent = round( 100 * $matchesCount / $totalCount, 2 ); $avgTime = round( $curTotalTime / $totalCount, 2 ); $avgCond = round( $curTotalConds / $totalCount, 1 ); $fields['abusefilter-edit-status-label'] = $this->msg( 'abusefilter-edit-status' ) ->numParams( $totalCount, $matchesCount, $matchesPercent, $avgTime, $avgCond ) ->parse(); } } $boxBuilder = $this->boxBuilderFactory->newEditBoxBuilder( $this, $user, $out ); $fields['abusefilter-edit-rules'] = $boxBuilder->buildEditBox( $filterObj->getRules(), true ); $fields['abusefilter-edit-notes'] = new OOUI\MultilineTextInputWidget( [ 'name' => 'wpFilterNotes', 'value' => $filterObj->getComments() . "\n", 'rows' => 15, 'readOnly' => $readOnly, 'id' => 'mw-abusefilter-notes-editor' ] ); // Build checkboxes $checkboxes = [ 'hidden', 'enabled', 'protected', 'deleted' ]; $flags = ''; if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) { $checkboxes[] = 'global'; } if ( $filterObj->isThrottled() ) { $throttledActionNames = array_intersect( $filterObj->getActionsNames(), $this->consequencesRegistry->getDangerousActionNames() ); if ( $throttledActionNames ) { $throttledActionsLocalized = []; foreach ( $throttledActionNames as $actionName ) { $throttledActionsLocalized[] = $this->specsFormatter->getActionMessage( $actionName )->text(); } $throttledMsg = $this->msg( 'abusefilter-edit-throttled-warning' ) ->plaintextParams( $lang->commaList( $throttledActionsLocalized ) ) ->params( count( $throttledActionsLocalized ) ) ->parseAsBlock(); } else { $throttledMsg = $this->msg( 'abusefilter-edit-throttled-warning-no-actions' ) ->parseAsBlock(); } $flags .= Html::warningBox( $throttledMsg ); } foreach ( $checkboxes as $checkboxId ) { // Messages that can be used here: // * abusefilter-edit-enabled // * abusefilter-edit-deleted // * abusefilter-edit-hidden // * abusefilter-edit-protected // * abusefilter-edit-global $message = "abusefilter-edit-$checkboxId"; // isEnabled(), isDeleted(), isHidden(), isProtected(), isGlobal() $method = 'is' . ucfirst( $checkboxId ); $postVar = 'wpFilter' . ucfirst( $checkboxId ); $checkboxAttribs = [ 'name' => $postVar, 'id' => $postVar, 'selected' => $filterObj->$method(), 'disabled' => $readOnly ]; $labelAttribs = [ 'label' => $this->msg( $message )->text(), 'align' => 'inline', ]; if ( $checkboxId === 'global' && !$this->afPermManager->canEditGlobal( $user ) ) { $checkboxAttribs['disabled'] = 'disabled'; } // Set readonly on deleted if the filter isn't disabled if ( $checkboxId === 'deleted' && $filterObj->isEnabled() ) { $checkboxAttribs['disabled'] = 'disabled'; } // Add infusable where needed if ( $checkboxId === 'deleted' || $checkboxId === 'enabled' ) { $checkboxAttribs['infusable'] = true; if ( $checkboxId === 'deleted' ) { $labelAttribs['id'] = $postVar . 'Label'; $labelAttribs['infusable'] = true; } } $checkbox = new OOUI\FieldLayout( new OOUI\CheckboxInputWidget( $checkboxAttribs ), $labelAttribs ); $flags .= $checkbox; } $fields['abusefilter-edit-flags'] = $flags; if ( $filter !== null ) { $tools = ''; if ( $this->afPermManager->canRevertFilterActions( $user ) ) { $tools .= Html::rawElement( 'p', [], $this->linkRenderer->makeLink( $this->getTitle( "revert/$filter" ), new HtmlArmor( $this->msg( 'abusefilter-edit-revert' )->parse() ) ) ); } if ( $this->afPermManager->canUseTestTools( $user ) ) { // Test link $tools .= Html::rawElement( 'p', [], $this->linkRenderer->makeLink( $this->getTitle( "test/$filter" ), new HtmlArmor( $this->msg( 'abusefilter-edit-test-link' )->parse() ) ) ); } // Last modification details $userLink = Linker::userLink( $filterObj->getUserID(), $filterObj->getUserName() ) . Linker::userToolLinks( $filterObj->getUserID(), $filterObj->getUserName() ); $fields['abusefilter-edit-lastmod'] = $this->msg( 'abusefilter-edit-lastmod-text' ) ->rawParams( $this->getLinkToLatestDiff( $filter, $lang->userTimeAndDate( $filterObj->getTimestamp(), $user ) ), $userLink, $this->getLinkToLatestDiff( $filter, $lang->userDate( $filterObj->getTimestamp(), $user ) ), $this->getLinkToLatestDiff( $filter, $lang->userTime( $filterObj->getTimestamp(), $user ) ) )->params( wfEscapeWikiText( $filterObj->getUserName() ) )->parse(); $history_display = new HtmlArmor( $this->msg( 'abusefilter-edit-viewhistory' )->parse() ); $fields['abusefilter-edit-history'] = $this->linkRenderer->makeKnownLink( $this->getTitle( 'history/' . $filter ), $history_display ); $exportText = $this->filterImporter->encodeData( $filterObj, $actions ); $tools .= Html::rawElement( 'a', [ 'href' => '#', 'id' => 'mw-abusefilter-export-link' ], $this->msg( 'abusefilter-edit-export' )->parse() ); $tools .= new OOUI\MultilineTextInputWidget( [ 'id' => 'mw-abusefilter-export', 'readOnly' => true, 'value' => $exportText, 'rows' => 10 ] ); $fields['abusefilter-edit-tools'] = $tools; } $form = Xml::fieldset( $this->msg( 'abusefilter-edit-main' )->text(), // TODO: deprecated, use OOUI or Codex widgets instead Xml::buildForm( $fields ) ); $form .= Xml::fieldset( $this->msg( 'abusefilter-edit-consequences' )->text(), $this->buildConsequenceEditor( $filterObj, $actions ) ); $urlFilter = $filter === null ? 'new' : (string)$filter; if ( !$readOnly ) { $form .= new OOUI\ButtonInputWidget( [ 'type' => 'submit', 'label' => $this->msg( 'abusefilter-edit-save' )->text(), 'useInputTag' => true, 'accesskey' => 's', 'flags' => [ 'progressive', 'primary' ] ] ); $form .= Html::hidden( 'wpEditToken', $this->getCsrfTokenSet()->getToken( [ 'abusefilter', $urlFilter ] )->toString() ); } $form = Html::rawElement( 'form', [ 'action' => $this->getTitle( $urlFilter )->getFullURL(), 'method' => 'post', 'id' => 'mw-abusefilter-editing-form' ], $form ); $out->addHTML( $form ); if ( $history_id ) { // @phan-suppress-next-line PhanPossiblyUndeclaredVariable,PhanTypeMismatchArgumentNullable $out->addWikiMsg( $oldWarningMessage, $history_id, $filter ); } } /** * Builds the "actions" editor for a given filter. * @param Filter $filterObj * @param array[] $actions Array of rows from the abuse_filter_action table * corresponding to the filter object * @return string HTML text for an action editor. */ private function buildConsequenceEditor( Filter $filterObj, array $actions ) { $enabledActions = $this->consequencesRegistry->getAllEnabledActionNames(); $setActions = []; foreach ( $enabledActions as $action ) { $setActions[$action] = array_key_exists( $action, $actions ); } $output = ''; foreach ( $enabledActions as $action ) { $params = $actions[$action] ?? null; $output .= $this->buildConsequenceSelector( $action, $setActions[$action], $filterObj, $params ); } return $output; } /** * @param string $action The action to build an editor for * @param bool $set Whether or not the action is activated * @param Filter $filterObj * @param string[]|null $parameters Action parameters. Null iff $set is false. * @return string|\OOUI\FieldLayout */ private function buildConsequenceSelector( $action, $set, $filterObj, ?array $parameters ) { $config = $this->getConfig(); $user = $this->getUser(); $actions = $this->consequencesRegistry->getAllEnabledActionNames(); if ( !in_array( $action, $actions, true ) ) { return ''; } $readOnly = !$this->afPermManager->canEditFilter( $user, $filterObj ); switch ( $action ) { case 'throttle': // Throttling is only available via object caching if ( $config->get( MainConfigNames::MainCacheType ) === CACHE_NONE ) { return ''; } $throttleSettings = new OOUI\FieldLayout( new OOUI\CheckboxInputWidget( [ 'name' => 'wpFilterActionThrottle', 'id' => 'mw-abusefilter-action-checkbox-throttle', 'selected' => $set, 'classes' => [ 'mw-abusefilter-action-checkbox' ], 'disabled' => $readOnly ] ), [ 'label' => $this->msg( 'abusefilter-edit-action-throttle' )->text(), 'align' => 'inline' ] ); $throttleFields = []; if ( $set ) { // @phan-suppress-next-line PhanTypeArraySuspiciousNullable $parameters is array here [ $throttleCount, $throttlePeriod ] = explode( ',', $parameters[1], 2 ); $throttleGroups = array_slice( $parameters, 2 ); } else { $throttleCount = 3; $throttlePeriod = 60; $throttleGroups = [ 'user' ]; } $throttleFields[] = new OOUI\FieldLayout( new OOUI\TextInputWidget( [ 'type' => 'number', 'name' => 'wpFilterThrottleCount', 'value' => $throttleCount, 'readOnly' => $readOnly ] ), [ 'label' => $this->msg( 'abusefilter-edit-throttle-count' )->text() ] ); $throttleFields[] = new OOUI\FieldLayout( new OOUI\TextInputWidget( [ 'type' => 'number', 'name' => 'wpFilterThrottlePeriod', 'value' => $throttlePeriod, 'readOnly' => $readOnly ] ), [ 'label' => $this->msg( 'abusefilter-edit-throttle-period' )->text() ] ); $groupsHelpLink = Html::element( 'a', [ 'href' => 'https://www.mediawiki.org/wiki/Special:MyLanguage/' . 'Extension:AbuseFilter/Actions#Throttling', 'target' => '_blank' ], $this->msg( 'abusefilter-edit-throttle-groups-help-text' )->text() ); $groupsHelp = $this->msg( 'abusefilter-edit-throttle-groups-help' ) ->rawParams( $groupsHelpLink )->escaped(); $hiddenGroups = new OOUI\FieldLayout( new OOUI\MultilineTextInputWidget( [ 'name' => 'wpFilterThrottleGroups', 'value' => implode( "\n", $throttleGroups ), 'rows' => 5, 'placeholder' => $this->msg( 'abusefilter-edit-throttle-hidden-placeholder' )->text(), 'infusable' => true, 'id' => 'mw-abusefilter-hidden-throttle-field', 'readOnly' => $readOnly ] ), [ 'label' => new OOUI\HtmlSnippet( $this->msg( 'abusefilter-edit-throttle-groups' )->parse() ), 'align' => 'top', 'id' => 'mw-abusefilter-hidden-throttle', 'help' => new OOUI\HtmlSnippet( $groupsHelp ), 'helpInline' => true ] ); $throttleFields[] = $hiddenGroups; $throttleConfig = [ 'values' => $throttleGroups, 'label' => $this->msg( 'abusefilter-edit-throttle-groups' )->parse(), 'disabled' => $readOnly, 'help' => $groupsHelp ]; $this->getOutput()->addJsConfigVars( 'throttleConfig', $throttleConfig ); $throttleSettings .= Html::rawElement( 'div', [ 'id' => 'mw-abusefilter-throttle-parameters' ], new OOUI\FieldsetLayout( [ 'items' => $throttleFields ] ) ); return $throttleSettings; case 'disallow': case 'warn': $output = ''; $formName = $action === 'warn' ? 'wpFilterActionWarn' : 'wpFilterActionDisallow'; $checkbox = new OOUI\FieldLayout( new OOUI\CheckboxInputWidget( [ 'name' => $formName, // mw-abusefilter-action-checkbox-warn, mw-abusefilter-action-checkbox-disallow 'id' => "mw-abusefilter-action-checkbox-$action", 'selected' => $set, 'classes' => [ 'mw-abusefilter-action-checkbox' ], 'disabled' => $readOnly ] ), [ // abusefilter-edit-action-warn, abusefilter-edit-action-disallow 'label' => $this->msg( "abusefilter-edit-action-$action" )->text(), 'align' => 'inline' ] ); $output .= $checkbox; $defaultWarnMsg = $config->get( 'AbuseFilterDefaultWarningMessage' ); $defaultDisallowMsg = $config->get( 'AbuseFilterDefaultDisallowMessage' ); if ( $set && isset( $parameters[0] ) ) { $msg = $parameters[0]; } elseif ( ( $action === 'warn' && isset( $defaultWarnMsg[$filterObj->getGroup()] ) ) || ( $action === 'disallow' && isset( $defaultDisallowMsg[$filterObj->getGroup()] ) ) ) { $msg = $action === 'warn' ? $defaultWarnMsg[$filterObj->getGroup()] : $defaultDisallowMsg[$filterObj->getGroup()]; } else { $msg = $action === 'warn' ? 'abusefilter-warning' : 'abusefilter-disallowed'; } $fields = []; $fields[] = $this->getExistingSelector( $msg, $readOnly, $action ); $otherFieldName = $action === 'warn' ? 'wpFilterWarnMessageOther' : 'wpFilterDisallowMessageOther'; $fields[] = new OOUI\FieldLayout( new OOUI\TextInputWidget( [ 'name' => $otherFieldName, 'value' => $msg, // mw-abusefilter-warn-message-other, mw-abusefilter-disallow-message-other 'id' => "mw-abusefilter-$action-message-other", 'infusable' => true, 'readOnly' => $readOnly ] ), [ 'label' => new OOUI\HtmlSnippet( // abusefilter-edit-warn-other-label, abusefilter-edit-disallow-other-label $this->msg( "abusefilter-edit-$action-other-label" )->parse() ) ] ); $previewButton = new OOUI\ButtonInputWidget( [ // abusefilter-edit-warn-preview, abusefilter-edit-disallow-preview 'label' => $this->msg( "abusefilter-edit-$action-preview" )->text(), // mw-abusefilter-warn-preview-button, mw-abusefilter-disallow-preview-button 'id' => "mw-abusefilter-$action-preview-button", 'infusable' => true, 'flags' => 'progressive' ] ); $buttonGroup = $previewButton; if ( $this->permissionManager->userHasRight( $user, 'editinterface' ) ) { $editButton = new OOUI\ButtonInputWidget( [ // abusefilter-edit-warn-edit, abusefilter-edit-disallow-edit 'label' => $this->msg( "abusefilter-edit-$action-edit" )->text(), // mw-abusefilter-warn-edit-button, mw-abusefilter-disallow-edit-button 'id' => "mw-abusefilter-$action-edit-button" ] ); $buttonGroup = new OOUI\Widget( [ 'content' => new OOUI\HorizontalLayout( [ 'items' => [ $previewButton, $editButton ], 'classes' => [ 'mw-abusefilter-preview-buttons', 'mw-abusefilter-javascript-tools' ] ] ) ] ); } $previewHolder = Html::rawElement( 'div', [ // mw-abusefilter-warn-preview, mw-abusefilter-disallow-preview 'id' => "mw-abusefilter-$action-preview", 'style' => 'display:none' ], '' ); $fields[] = $buttonGroup; $output .= Html::rawElement( 'div', // mw-abusefilter-warn-parameters, mw-abusefilter-disallow-parameters [ 'id' => "mw-abusefilter-$action-parameters" ], new OOUI\FieldsetLayout( [ 'items' => $fields ] ) ) . $previewHolder; return $output; case 'tag': $tags = $set ? $parameters : []; '@phan-var string[] $parameters'; $output = ''; $checkbox = new OOUI\FieldLayout( new OOUI\CheckboxInputWidget( [ 'name' => 'wpFilterActionTag', 'id' => 'mw-abusefilter-action-checkbox-tag', 'selected' => $set, 'classes' => [ 'mw-abusefilter-action-checkbox' ], 'disabled' => $readOnly ] ), [ 'label' => $this->msg( 'abusefilter-edit-action-tag' )->text(), 'align' => 'inline' ] ); $output .= $checkbox; $tagConfig = [ 'values' => $tags, 'label' => $this->msg( 'abusefilter-edit-tag-tag' )->parse(), 'disabled' => $readOnly ]; $this->getOutput()->addJsConfigVars( 'tagConfig', $tagConfig ); $hiddenTags = new OOUI\FieldLayout( new OOUI\MultilineTextInputWidget( [ 'name' => 'wpFilterTags', 'value' => implode( ',', $tags ), 'rows' => 5, 'placeholder' => $this->msg( 'abusefilter-edit-tag-hidden-placeholder' )->text(), 'infusable' => true, 'id' => 'mw-abusefilter-hidden-tag-field', 'readOnly' => $readOnly ] ), [ 'label' => new OOUI\HtmlSnippet( $this->msg( 'abusefilter-edit-tag-tag' )->parse() ), 'align' => 'top', 'id' => 'mw-abusefilter-hidden-tag' ] ); $output .= Html::rawElement( 'div', [ 'id' => 'mw-abusefilter-tag-parameters' ], $hiddenTags ); return $output; case 'block': if ( $set && count( $parameters ) === 3 ) { // Both blocktalk and custom block durations available [ $blockTalk, $defaultAnonDuration, $defaultUserDuration ] = $parameters; } else { if ( $set && count( $parameters ) === 1 ) { // Only blocktalk available $blockTalk = $parameters[0]; } $defaultAnonDuration = $config->get( 'AbuseFilterAnonBlockDuration' ) ?? $config->get( 'AbuseFilterBlockDuration' ); $defaultUserDuration = $config->get( 'AbuseFilterBlockDuration' ); } $suggestedBlocks = $this->getLanguage()->getBlockDurations( false ); $suggestedBlocks = self::normalizeBlocks( $suggestedBlocks ); $output = ''; $checkbox = new OOUI\FieldLayout( new OOUI\CheckboxInputWidget( [ 'name' => 'wpFilterActionBlock', 'id' => 'mw-abusefilter-action-checkbox-block', 'selected' => $set, 'classes' => [ 'mw-abusefilter-action-checkbox' ], 'disabled' => $readOnly ] ), [ 'label' => $this->msg( 'abusefilter-edit-action-block' )->text(), 'align' => 'inline' ] ); $output .= $checkbox; $suggestedBlocks = Html::listDropdownOptionsOoui( $suggestedBlocks ); $anonDuration = new OOUI\DropdownInputWidget( [ 'name' => 'wpBlockAnonDuration', 'options' => $suggestedBlocks, 'value' => $defaultAnonDuration, 'disabled' => $readOnly ] ); $userDuration = new OOUI\DropdownInputWidget( [ 'name' => 'wpBlockUserDuration', 'options' => $suggestedBlocks, 'value' => $defaultUserDuration, 'disabled' => $readOnly ] ); $blockOptions = []; if ( $config->get( MainConfigNames::BlockAllowsUTEdit ) === true ) { $talkCheckbox = new OOUI\FieldLayout( new OOUI\CheckboxInputWidget( [ 'name' => 'wpFilterBlockTalk', 'id' => 'mw-abusefilter-action-checkbox-blocktalk', 'selected' => isset( $blockTalk ) && $blockTalk === 'blocktalk', 'classes' => [ 'mw-abusefilter-action-checkbox' ], 'disabled' => $readOnly ] ), [ 'label' => $this->msg( 'abusefilter-edit-action-blocktalk' )->text(), 'align' => 'left' ] ); $blockOptions[] = $talkCheckbox; } $blockOptions[] = new OOUI\FieldLayout( $anonDuration, [ 'label' => $this->msg( 'abusefilter-edit-block-anon-durations' )->text() ] ); $blockOptions[] = new OOUI\FieldLayout( $userDuration, [ 'label' => $this->msg( 'abusefilter-edit-block-user-durations' )->text() ] ); $output .= Html::rawElement( 'div', [ 'id' => 'mw-abusefilter-block-parameters' ], new OOUI\FieldsetLayout( [ 'items' => $blockOptions ] ) ); return $output; default: // Give grep a chance to find the usages: // abusefilter-edit-action-disallow, // abusefilter-edit-action-blockautopromote, // abusefilter-edit-action-degroup, // abusefilter-edit-action-rangeblock, $message = 'abusefilter-edit-action-' . $action; $form_field = 'wpFilterAction' . ucfirst( $action ); $status = $set; $thisAction = new OOUI\FieldLayout( new OOUI\CheckboxInputWidget( [ 'name' => $form_field, 'id' => "mw-abusefilter-action-checkbox-$action", 'selected' => $status, 'classes' => [ 'mw-abusefilter-action-checkbox' ], 'disabled' => $readOnly ] ), [ 'label' => $this->msg( $message )->text(), 'align' => 'inline' ] ); return $thisAction; } } /** * @param string $warnMsg * @param bool $readOnly * @param string $action * @return \OOUI\FieldLayout */ public function getExistingSelector( $warnMsg, $readOnly = false, $action = 'warn' ) { if ( $action === 'warn' ) { $action = 'warning'; $formId = 'warn'; $inputName = 'wpFilterWarnMessage'; } elseif ( $action === 'disallow' ) { $action = 'disallowed'; $formId = 'disallow'; $inputName = 'wpFilterDisallowMessage'; } else { throw new UnexpectedValueException( "Unexpected action value $action" ); } $existingSelector = new OOUI\DropdownInputWidget( [ 'name' => $inputName, // mw-abusefilter-warn-message-existing, mw-abusefilter-disallow-message-existing 'id' => "mw-abusefilter-$formId-message-existing", // abusefilter-warning, abusefilter-disallowed 'value' => $warnMsg === "abusefilter-$action" ? "abusefilter-$action" : 'other', 'infusable' => true ] ); // abusefilter-warning, abusefilter-disallowed $options = [ "abusefilter-$action" => "abusefilter-$action" ]; if ( $readOnly ) { $existingSelector->setDisabled( true ); } else { // Find other messages. $dbr = $this->lbFactory->getReplicaDatabase(); $pageTitlePrefix = "Abusefilter-$action"; $titles = $dbr->newSelectQueryBuilder() ->select( 'page_title' ) ->from( 'page' ) ->where( [ 'page_namespace' => 8, $dbr->expr( 'page_title', IExpression::LIKE, new LikeValue( $pageTitlePrefix, $dbr->anyString() ) ) ] ) ->caller( __METHOD__ ) ->fetchFieldValues(); $lang = $this->getLanguage(); foreach ( $titles as $title ) { if ( $lang->lcfirst( $title ) === $lang->lcfirst( $warnMsg ) ) { $existingSelector->setValue( $lang->lcfirst( $warnMsg ) ); } if ( $title !== "Abusefilter-$action" ) { $options[ $lang->lcfirst( $title ) ] = $lang->lcfirst( $title ); } } } // abusefilter-edit-warn-other, abusefilter-edit-disallow-other $options[ $this->msg( "abusefilter-edit-$formId-other" )->text() ] = 'other'; $options = Html::listDropdownOptionsOoui( $options ); $existingSelector->setOptions( $options ); $existingSelector = new OOUI\FieldLayout( $existingSelector, [ // abusefilter-edit-warn-message, abusefilter-edit-disallow-message 'label' => $this->msg( "abusefilter-edit-$formId-message" )->text() ] ); return $existingSelector; } /** * @todo Maybe we should also check if global values belong to $durations * and determine the right point to add them if missing. * * @param string[] $durations * @return string[] */ private static function normalizeBlocks( array $durations ) { global $wgAbuseFilterBlockDuration, $wgAbuseFilterAnonBlockDuration; // We need to have same values since it may happen that ipblocklist // and one (or both) of the global variables use different wording // for the same duration. In such case, when setting the default of // the dropdowns it would fail. $anonDuration = self::getAbsoluteBlockDuration( $wgAbuseFilterAnonBlockDuration ?? $wgAbuseFilterBlockDuration ); $userDuration = self::getAbsoluteBlockDuration( $wgAbuseFilterBlockDuration ); foreach ( $durations as &$duration ) { $currentDuration = self::getAbsoluteBlockDuration( $duration ); if ( $duration !== $wgAbuseFilterBlockDuration && $currentDuration === $userDuration ) { $duration = $wgAbuseFilterBlockDuration; } elseif ( $duration !== $wgAbuseFilterAnonBlockDuration && $currentDuration === $anonDuration ) { $duration = $wgAbuseFilterAnonBlockDuration; } } return $durations; } /** * Converts a string duration to an absolute timestamp, i.e. unrelated to the current * time, taking into account infinity durations as well. The second parameter of * strtotime is set to 0 in order to convert the duration in seconds (instead of * a timestamp), thus making it unaffected by the execution time of the code. * * @param string $duration * @return string|int */ private static function getAbsoluteBlockDuration( $duration ) { if ( wfIsInfinity( $duration ) ) { return 'infinity'; } return strtotime( $duration, 0 ); } /** * Loads filter data from the database by ID. * @param int|null $id The filter's ID number, or null for a new filter * @return Filter * @throws FilterNotFoundException */ private function loadFilterData( ?int $id ): Filter { if ( $id === null ) { return MutableFilter::newDefault(); } $flags = $this->getRequest()->wasPosted() // Load from primary database to avoid unintended reversions where there's replication lag. ? IDBAccessObject::READ_LATEST : IDBAccessObject::READ_NORMAL; return $this->filterLookup->getFilter( $id, false, $flags ); } /** * Load filter data to show in the edit view from the DB. * @param int|null $filter The filter ID being requested or null for a new filter * @param int|null $history_id If any, the history ID being requested. * @return Filter|null Null if the filter does not exist. */ private function loadFromDatabase( ?int $filter, $history_id = null ): ?Filter { if ( $history_id ) { try { return $this->filterLookup->getFilterVersion( $history_id ); } catch ( FilterVersionNotFoundException $_ ) { return null; } } else { return $this->loadFilterData( $filter ); } } /** * Load data from the HTTP request. Used for saving the filter, and when the token doesn't match * @param int|null $filter * @return Filter[] */ private function loadRequest( ?int $filter ): array { $request = $this->getRequest(); if ( !$request->wasPosted() ) { // Sanity throw new LogicException( __METHOD__ . ' called without the request being POSTed.' ); } $origFilter = $this->loadFilterData( $filter ); /** @var MutableFilter $newFilter */ $newFilter = $origFilter instanceof MutableFilter ? clone $origFilter : MutableFilter::newFromParentFilter( $origFilter ); if ( $filter !== null ) { // Unchangeable values // @phan-suppress-next-line PhanTypeMismatchArgumentNullable $newFilter->setThrottled( $origFilter->isThrottled() ); // @phan-suppress-next-line PhanTypeMismatchArgumentNullable $newFilter->setHitCount( $origFilter->getHitCount() ); // These are needed if the save fails and the filter is not new $newFilter->setID( $origFilter->getID() ); $newFilter->setUserID( $origFilter->getUserID() ); $newFilter->setUserName( $origFilter->getUserName() ); $newFilter->setTimestamp( $origFilter->getTimestamp() ); } $newFilter->setName( trim( $request->getVal( 'wpFilterDescription' ) ) ); $newFilter->setRules( trim( $request->getVal( 'wpFilterRules' ) ) ); $newFilter->setComments( trim( $request->getVal( 'wpFilterNotes' ) ) ); $newFilter->setGroup( $request->getVal( 'wpFilterGroup', 'default' ) ); $newFilter->setDeleted( $request->getCheck( 'wpFilterDeleted' ) ); $newFilter->setEnabled( $request->getCheck( 'wpFilterEnabled' ) ); $newFilter->setHidden( $request->getCheck( 'wpFilterHidden' ) ); $newFilter->setProtected( $request->getCheck( 'wpFilterProtected' ) ); $newFilter->setGlobal( $request->getCheck( 'wpFilterGlobal' ) && $this->getConfig()->get( 'AbuseFilterIsCentral' ) ); $actions = $this->loadActions(); $newFilter->setActions( $actions ); return [ $newFilter, $origFilter ]; } /** * @return Filter|null */ private function loadImportRequest(): ?Filter { $request = $this->getRequest(); if ( !$request->wasPosted() ) { // Sanity throw new LogicException( __METHOD__ . ' called without the request being POSTed.' ); } try { $filter = $this->filterImporter->decodeData( $request->getVal( 'wpImportText' ) ); } catch ( InvalidImportDataException $_ ) { return null; } return $filter; } /** * @return array[] */ private function loadActions(): array { $request = $this->getRequest(); $allActions = $this->consequencesRegistry->getAllEnabledActionNames(); $actions = []; foreach ( $allActions as $action ) { // Check if it's set $enabled = $request->getCheck( 'wpFilterAction' . ucfirst( $action ) ); if ( $enabled ) { $parameters = []; if ( $action === 'throttle' ) { // We need to load the parameters $throttleCount = $request->getIntOrNull( 'wpFilterThrottleCount' ); $throttlePeriod = $request->getIntOrNull( 'wpFilterThrottlePeriod' ); // First explode with \n, which is the delimiter used in the textarea $rawGroups = explode( "\n", $request->getText( 'wpFilterThrottleGroups' ) ); // Trim any space, both as an actual group and inside subgroups $throttleGroups = []; foreach ( $rawGroups as $group ) { if ( strpos( $group, ',' ) !== false ) { $subGroups = explode( ',', $group ); $throttleGroups[] = implode( ',', array_map( 'trim', $subGroups ) ); } elseif ( trim( $group ) !== '' ) { $throttleGroups[] = trim( $group ); } } $parameters[0] = $this->filter; $parameters[1] = "$throttleCount,$throttlePeriod"; $parameters = array_merge( $parameters, $throttleGroups ); } elseif ( $action === 'warn' ) { $specMsg = $request->getVal( 'wpFilterWarnMessage' ); if ( $specMsg === 'other' ) { $specMsg = $request->getVal( 'wpFilterWarnMessageOther' ); } $parameters[0] = $specMsg; } elseif ( $action === 'block' ) { // TODO: Should save a boolean $parameters[0] = $request->getCheck( 'wpFilterBlockTalk' ) ? 'blocktalk' : 'noTalkBlockSet'; $parameters[1] = $request->getVal( 'wpBlockAnonDuration' ); $parameters[2] = $request->getVal( 'wpBlockUserDuration' ); } elseif ( $action === 'disallow' ) { $specMsg = $request->getVal( 'wpFilterDisallowMessage' ); if ( $specMsg === 'other' ) { $specMsg = $request->getVal( 'wpFilterDisallowMessageOther' ); } $parameters[0] = $specMsg; } elseif ( $action === 'tag' ) { $parameters = explode( ',', trim( $request->getText( 'wpFilterTags' ) ) ); if ( $parameters === [ '' ] ) { // Since it's not possible to manually add an empty tag, this only happens // if the form is submitted without touching the tag input field. // We pass an empty array so that the widget won't show an empty tag in the topbar $parameters = []; } } $actions[$action] = $parameters; } } return $actions; } /** * Exports the default warning and disallow messages to a JS variable */ private function exposeMessages() { $this->getOutput()->addJsConfigVars( 'wgAbuseFilterDefaultWarningMessage', $this->getConfig()->get( 'AbuseFilterDefaultWarningMessage' ) ); $this->getOutput()->addJsConfigVars( 'wgAbuseFilterDefaultDisallowMessage', $this->getConfig()->get( 'AbuseFilterDefaultDisallowMessage' ) ); } }