Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
85.71% |
102 / 119 |
|
75.00% |
3 / 4 |
CRAP | |
0.00% |
0 / 1 |
| FilterStore | |
85.71% |
102 / 119 |
|
75.00% |
3 / 4 |
29.13 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| saveFilter | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
3 | |||
| doSaveFilter | |
81.32% |
74 / 91 |
|
0.00% |
0 / 1 |
23.88 | |||
| filterToDatabaseRow | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace MediaWiki\Extension\AbuseFilter; |
| 4 | |
| 5 | use MediaWiki\Extension\AbuseFilter\ChangeTags\ChangeTagsManager; |
| 6 | use MediaWiki\Extension\AbuseFilter\Consequences\ConsequencesRegistry; |
| 7 | use MediaWiki\Extension\AbuseFilter\Filter\Filter; |
| 8 | use MediaWiki\Extension\AbuseFilter\Filter\Flags; |
| 9 | use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseFilter; |
| 10 | use MediaWiki\Logging\ManualLogEntry; |
| 11 | use MediaWiki\Permissions\Authority; |
| 12 | use MediaWiki\Status\Status; |
| 13 | use MediaWiki\User\ActorNormalization; |
| 14 | use MediaWiki\User\UserIdentity; |
| 15 | use Wikimedia\Rdbms\LBFactory; |
| 16 | |
| 17 | /** |
| 18 | * @internal |
| 19 | */ |
| 20 | class FilterStore { |
| 21 | public const SERVICE_NAME = 'AbuseFilterFilterStore'; |
| 22 | |
| 23 | public function __construct( |
| 24 | private readonly ConsequencesRegistry $consequencesRegistry, |
| 25 | private readonly LBFactory $lbFactory, |
| 26 | private readonly ActorNormalization $actorNormalization, |
| 27 | private readonly FilterProfiler $filterProfiler, |
| 28 | private readonly FilterLookup $filterLookup, |
| 29 | private readonly ChangeTagsManager $tagsManager, |
| 30 | private readonly FilterValidator $filterValidator, |
| 31 | private readonly FilterCompare $filterCompare, |
| 32 | private readonly EmergencyCache $emergencyCache |
| 33 | ) { |
| 34 | } |
| 35 | |
| 36 | /** |
| 37 | * Checks whether user input for the filter editing form is valid and if so saves the filter. |
| 38 | * Returns a Status object which can be: |
| 39 | * - Good with [ new_filter_id, history_id ] as value if the filter was successfully saved |
| 40 | * - Good with value = false if everything went fine but the filter is unchanged |
| 41 | * - OK with errors if a validation error occurred |
| 42 | * - Fatal in case of a permission-related error |
| 43 | * |
| 44 | * @param Authority $performer |
| 45 | * @param int|null $filterId |
| 46 | * @param Filter $newFilter |
| 47 | * @param Filter $originalFilter |
| 48 | * @return Status |
| 49 | */ |
| 50 | public function saveFilter( |
| 51 | Authority $performer, |
| 52 | ?int $filterId, |
| 53 | Filter $newFilter, |
| 54 | Filter $originalFilter |
| 55 | ): Status { |
| 56 | $validationStatus = $this->filterValidator->checkAll( $newFilter, $originalFilter, $performer ); |
| 57 | if ( !$validationStatus->isGood() ) { |
| 58 | return $validationStatus; |
| 59 | } |
| 60 | |
| 61 | // Check for non-changes |
| 62 | $differences = $this->filterCompare->compareVersions( $newFilter, $originalFilter ); |
| 63 | if ( !$differences ) { |
| 64 | return Status::newGood( false ); |
| 65 | } |
| 66 | |
| 67 | // Everything went fine, so let's save the filter |
| 68 | [ $newID, $historyID ] = $this->doSaveFilter( |
| 69 | $performer->getUser(), $newFilter, $originalFilter, $differences, $filterId ); |
| 70 | return Status::newGood( [ $newID, $historyID ] ); |
| 71 | } |
| 72 | |
| 73 | /** |
| 74 | * Saves new filter's info to DB |
| 75 | * |
| 76 | * @param UserIdentity $userIdentity |
| 77 | * @param Filter $newFilter |
| 78 | * @param Filter $originalFilter |
| 79 | * @param array $differences |
| 80 | * @param int|null $filterId |
| 81 | * @return int[] first element is new ID, second is history ID |
| 82 | */ |
| 83 | private function doSaveFilter( |
| 84 | UserIdentity $userIdentity, |
| 85 | Filter $newFilter, |
| 86 | Filter $originalFilter, |
| 87 | array $differences, |
| 88 | ?int $filterId |
| 89 | ): array { |
| 90 | $dbw = $this->lbFactory->getPrimaryDatabase(); |
| 91 | $newRow = $this->filterToDatabaseRow( $newFilter, $originalFilter ); |
| 92 | |
| 93 | // Set last modifier. |
| 94 | $newRow['af_timestamp'] = $dbw->timestamp(); |
| 95 | $newRow['af_actor'] = $this->actorNormalization->acquireActorId( $userIdentity, $dbw ); |
| 96 | |
| 97 | $isNew = $filterId === null; |
| 98 | |
| 99 | // Preserve the old throttled status (if any) only if disabling the filter. |
| 100 | // TODO: It might make more sense to check what was actually changed |
| 101 | $newRow['af_throttled'] = ( $newRow['af_throttled'] ?? false ) && !$newRow['af_enabled']; |
| 102 | // This is null when creating a new filter, but the DB field is NOT NULL |
| 103 | $newRow['af_hit_count'] ??= 0; |
| 104 | $rowForInsert = array_diff_key( $newRow, [ 'af_id' => true ] ); |
| 105 | |
| 106 | $dbw->startAtomic( __METHOD__ ); |
| 107 | if ( $filterId === null ) { |
| 108 | $dbw->newInsertQueryBuilder() |
| 109 | ->insertInto( 'abuse_filter' ) |
| 110 | ->row( $rowForInsert ) |
| 111 | ->caller( __METHOD__ ) |
| 112 | ->execute(); |
| 113 | $filterId = $dbw->insertId(); |
| 114 | } else { |
| 115 | $dbw->newUpdateQueryBuilder() |
| 116 | ->update( 'abuse_filter' ) |
| 117 | ->set( $rowForInsert ) |
| 118 | ->where( [ 'af_id' => $filterId ] ) |
| 119 | ->caller( __METHOD__ ) |
| 120 | ->execute(); |
| 121 | } |
| 122 | $newRow['af_id'] = $filterId; |
| 123 | |
| 124 | $actions = $newFilter->getActions(); |
| 125 | $actionsRows = []; |
| 126 | foreach ( $this->consequencesRegistry->getAllEnabledActionNames() as $action ) { |
| 127 | if ( !isset( $actions[$action] ) ) { |
| 128 | continue; |
| 129 | } |
| 130 | |
| 131 | $parameters = $actions[$action]; |
| 132 | if ( $action === 'throttle' && $parameters[0] === null ) { |
| 133 | // FIXME: Do we really need to keep the filter ID inside throttle parameters? |
| 134 | // We'd save space, keep things simpler and avoid this hack. Note: if removing |
| 135 | // it, a maintenance script will be necessary to clean up the table. |
| 136 | $parameters[0] = $filterId; |
| 137 | } |
| 138 | |
| 139 | $actionsRows[] = [ |
| 140 | 'afa_filter' => $filterId, |
| 141 | 'afa_consequence' => $action, |
| 142 | 'afa_parameters' => implode( "\n", $parameters ), |
| 143 | ]; |
| 144 | } |
| 145 | |
| 146 | // Create a history row |
| 147 | $afhRow = []; |
| 148 | |
| 149 | foreach ( AbuseFilter::HISTORY_MAPPINGS as $afCol => $afhCol ) { |
| 150 | // Some fields are expected to be missing during actor migration |
| 151 | if ( isset( $newRow[$afCol] ) ) { |
| 152 | $afhRow[$afhCol] = $newRow[$afCol]; |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | $afhRow['afh_actions'] = serialize( $actions ); |
| 157 | |
| 158 | $afhRow['afh_changed_fields'] = implode( ',', $differences ); |
| 159 | |
| 160 | $flags = []; |
| 161 | if ( FilterUtils::isHidden( $newRow['af_hidden'] ) ) { |
| 162 | $flags[] = 'hidden'; |
| 163 | } |
| 164 | if ( FilterUtils::isProtected( $newRow['af_hidden'] ) ) { |
| 165 | $flags[] = 'protected'; |
| 166 | } |
| 167 | if ( $newRow['af_enabled'] ) { |
| 168 | $flags[] = 'enabled'; |
| 169 | } |
| 170 | if ( $newRow['af_deleted'] ) { |
| 171 | $flags[] = 'deleted'; |
| 172 | } |
| 173 | if ( $newRow['af_global'] ) { |
| 174 | $flags[] = 'global'; |
| 175 | } |
| 176 | |
| 177 | $afhRow['afh_flags'] = implode( ',', $flags ); |
| 178 | |
| 179 | $afhRow['afh_filter'] = $filterId; |
| 180 | |
| 181 | // Do the update |
| 182 | $dbw->newInsertQueryBuilder() |
| 183 | ->insertInto( 'abuse_filter_history' ) |
| 184 | ->row( $afhRow ) |
| 185 | ->caller( __METHOD__ ) |
| 186 | ->execute(); |
| 187 | $historyID = $dbw->insertId(); |
| 188 | if ( !$isNew ) { |
| 189 | $dbw->newDeleteQueryBuilder() |
| 190 | ->deleteFrom( 'abuse_filter_action' ) |
| 191 | ->where( [ 'afa_filter' => $filterId ] ) |
| 192 | ->caller( __METHOD__ ) |
| 193 | ->execute(); |
| 194 | } |
| 195 | if ( $actionsRows ) { |
| 196 | $dbw->newInsertQueryBuilder() |
| 197 | ->insertInto( 'abuse_filter_action' ) |
| 198 | ->rows( $actionsRows ) |
| 199 | ->caller( __METHOD__ ) |
| 200 | ->execute(); |
| 201 | } |
| 202 | |
| 203 | $dbw->endAtomic( __METHOD__ ); |
| 204 | |
| 205 | // Invalidate cache if this was a global rule |
| 206 | if ( $originalFilter->isGlobal() || $newRow['af_global'] ) { |
| 207 | $this->filterLookup->purgeGroupWANCache( $newRow['af_group'] ); |
| 208 | } |
| 209 | |
| 210 | // Logging |
| 211 | $logEntry = new ManualLogEntry( 'abusefilter', $isNew ? 'create' : 'modify' ); |
| 212 | $logEntry->setPerformer( $userIdentity ); |
| 213 | $logEntry->setTarget( SpecialAbuseFilter::getTitleForSubpage( (string)$filterId ) ); |
| 214 | $logEntry->setParameters( [ |
| 215 | 'historyId' => $historyID, |
| 216 | 'newId' => $filterId |
| 217 | ] ); |
| 218 | $logid = $logEntry->insert( $dbw ); |
| 219 | $logEntry->publish( $logid ); |
| 220 | |
| 221 | // Purge the tag list cache so the fetchAllTags hook applies tag changes |
| 222 | if ( isset( $actions['tag'] ) ) { |
| 223 | $this->tagsManager->purgeTagCache(); |
| 224 | } |
| 225 | |
| 226 | $this->filterProfiler->resetFilterProfile( $filterId ); |
| 227 | if ( $newRow['af_enabled'] ) { |
| 228 | $this->emergencyCache->setNewForFilter( $filterId, $newRow['af_group'] ); |
| 229 | } |
| 230 | return [ $filterId, $historyID ]; |
| 231 | } |
| 232 | |
| 233 | /** |
| 234 | * @todo Perhaps add validation to ensure no null values remained. |
| 235 | * @note For simplicity, data about the last editor are omitted. |
| 236 | * @param Filter $filter |
| 237 | * @return array |
| 238 | */ |
| 239 | private function filterToDatabaseRow( Filter $filter, Filter $originalFilter ): array { |
| 240 | // T67807: integer 1's & 0's might be better understood than booleans |
| 241 | |
| 242 | // If the filter is already protected, it must remain protected even if |
| 243 | // the current filter doesn't use a protected variable anymore |
| 244 | // FIXME: Resposibility for this is currently unclear. It should be |
| 245 | // enforced prior to the FilterCompare::compareVersions call to avoid |
| 246 | // dummy filter versions. |
| 247 | $privacyLevel = $filter->getPrivacyLevel(); |
| 248 | if ( $originalFilter->isProtected() ) { |
| 249 | $privacyLevel |= Flags::FILTER_USES_PROTECTED_VARS; |
| 250 | } |
| 251 | |
| 252 | return [ |
| 253 | 'af_id' => $filter->getID(), |
| 254 | 'af_pattern' => $filter->getRules(), |
| 255 | 'af_public_comments' => $filter->getName(), |
| 256 | 'af_comments' => $filter->getComments(), |
| 257 | 'af_group' => $filter->getGroup(), |
| 258 | 'af_actions' => implode( ',', $filter->getActionsNames() ), |
| 259 | 'af_enabled' => (int)$filter->isEnabled(), |
| 260 | 'af_deleted' => (int)$filter->isDeleted(), |
| 261 | 'af_hidden' => $privacyLevel, |
| 262 | 'af_global' => (int)$filter->isGlobal(), |
| 263 | 'af_timestamp' => $filter->getTimestamp(), |
| 264 | 'af_hit_count' => $filter->getHitCount(), |
| 265 | 'af_throttled' => (int)$filter->isThrottled(), |
| 266 | ]; |
| 267 | } |
| 268 | } |