Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.72% covered (warning)
77.72%
157 / 202
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
QueryAbuseLog
77.72% covered (warning)
77.72%
157 / 202
50.00% covered (danger)
50.00%
1 / 2
104.50
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
77.61% covered (warning)
77.61%
156 / 201
0.00% covered (danger)
0.00%
0 / 1
95.75
 getAllowedParams
n/a
0 / 0
n/a
0 / 0
2
 getExamplesMessages
n/a
0 / 0
n/a
0 / 0
1
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 */
18
19namespace MediaWiki\Extension\AbuseFilter\Api;
20
21use InvalidArgumentException;
22use MediaWiki\Api\ApiBase;
23use MediaWiki\Api\ApiQuery;
24use MediaWiki\Api\ApiQueryBase;
25use MediaWiki\Extension\AbuseFilter\AbuseFilterPermissionManager;
26use MediaWiki\Extension\AbuseFilter\AbuseLoggerFactory;
27use MediaWiki\Extension\AbuseFilter\CentralDBNotAvailableException;
28use MediaWiki\Extension\AbuseFilter\Filter\FilterNotFoundException;
29use MediaWiki\Extension\AbuseFilter\Filter\MutableFilter;
30use MediaWiki\Extension\AbuseFilter\FilterLookup;
31use MediaWiki\Extension\AbuseFilter\GlobalNameUtils;
32use MediaWiki\Extension\AbuseFilter\Parser\RuleCheckerFactory;
33use MediaWiki\Extension\AbuseFilter\Special\SpecialAbuseLog;
34use MediaWiki\Extension\AbuseFilter\Variables\VariablesBlobStore;
35use MediaWiki\Extension\AbuseFilter\Variables\VariablesManager;
36use MediaWiki\Title\Title;
37use MediaWiki\User\UserFactory;
38use MediaWiki\Utils\MWTimestamp;
39use Wikimedia\IPUtils;
40use Wikimedia\ParamValidator\ParamValidator;
41use Wikimedia\ParamValidator\TypeDef\IntegerDef;
42
43/**
44 * Query module to list abuse log entries.
45 *
46 * @copyright 2009 Alex Z. <mrzmanwiki AT gmail DOT com>
47 * Based mostly on code by Bryan Tong Minh and Roan Kattouw
48 *
49 * @ingroup API
50 * @ingroup Extensions
51 */
52class QueryAbuseLog extends ApiQueryBase {
53
54    public function __construct(
55        ApiQuery $query,
56        string $moduleName,
57        private readonly FilterLookup $afFilterLookup,
58        private readonly AbuseFilterPermissionManager $afPermManager,
59        private readonly VariablesBlobStore $afVariablesBlobStore,
60        private readonly VariablesManager $afVariablesManager,
61        private readonly UserFactory $userFactory,
62        private readonly AbuseLoggerFactory $abuseLoggerFactory,
63        private readonly RuleCheckerFactory $ruleCheckerFactory
64    ) {
65        parent::__construct( $query, $moduleName, 'afl' );
66    }
67
68    /**
69     * @inheritDoc
70     */
71    public function execute() {
72        $lookup = $this->afFilterLookup;
73
74        // Same check as in SpecialAbuseLog
75        $this->checkUserRightsAny( 'abusefilter-log' );
76
77        $performer = $this->getAuthority();
78        $params = $this->extractRequestParams();
79
80        $prop = array_fill_keys( $params['prop'], true );
81        $fld_ids = isset( $prop['ids'] );
82        $fld_filter = isset( $prop['filter'] );
83        $fld_user = isset( $prop['user'] );
84        $fld_title = isset( $prop['title'] );
85        $fld_action = isset( $prop['action'] );
86        $fld_details = isset( $prop['details'] );
87        $fld_result = isset( $prop['result'] );
88        $fld_timestamp = isset( $prop['timestamp'] );
89        $fld_hidden = isset( $prop['hidden'] );
90        $fld_revid = isset( $prop['revid'] );
91        $isCentral = $this->getConfig()->get( 'AbuseFilterIsCentral' );
92        $fld_wiki = $isCentral && isset( $prop['wiki'] );
93
94        if ( $fld_details ) {
95            $this->checkUserRightsAny( 'abusefilter-log-detail' );
96        }
97
98        $canViewPrivate = $this->afPermManager->canViewPrivateFiltersLogs( $performer );
99
100        // Map of [ [ id, global ], ... ]
101        $searchFilters = [];
102        // Match permissions for viewing events on private filters to SpecialAbuseLog (bug 42814)
103        // @todo Avoid code duplication with SpecialAbuseLog::showList, make it so that, if hidden
104        // filters are specified, we only filter them out instead of failing.
105        if ( $params['filter'] ) {
106            if ( !is_array( $params['filter'] ) ) {
107                $params['filter'] = [ $params['filter'] ];
108            }
109            $foundInvalid = false;
110            foreach ( $params['filter'] as $filter ) {
111                try {
112                    $searchFilters[] = GlobalNameUtils::splitGlobalName( $filter );
113                } catch ( InvalidArgumentException ) {
114                    $foundInvalid = true;
115                    continue;
116                }
117            }
118
119            foreach ( $searchFilters as [ $filterID, $global ] ) {
120                try {
121                    $filter = $lookup->getFilter( $filterID, $global );
122                    $ruleChecker = $this->ruleCheckerFactory->newRuleChecker();
123                    $usedVariables = $ruleChecker->getUsedVars( $filter->getRules() );
124                } catch ( CentralDBNotAvailableException $_ ) {
125                    // Conservatively assume that it's hidden and protected, like in AbuseLogPager::doFormatRow.
126                    // Also assume that the filter contains all protected variables for the same reasons.
127                    $filter = MutableFilter::newDefault();
128                    $filter->setHidden( true );
129                    $filter->setProtected( true );
130                    $usedVariables = $this->afPermManager->getProtectedVariables();
131                } catch ( FilterNotFoundException $_ ) {
132                    // If no filter is found, assume it has no restrictions (is public and uses no protected
133                    // variables) because it should be an non-existing filter ID.
134                    $filter = MutableFilter::newDefault();
135                    $usedVariables = [];
136                    $foundInvalid = true;
137                }
138
139                if ( !$canViewPrivate && $filter->isHidden() ) {
140                    $this->dieWithError(
141                        [ 'apierror-permissiondenied', $this->msg( 'action-abusefilter-log-private' ) ]
142                    );
143                }
144
145                if ( $filter->isProtected() ) {
146                    $protectedVariableAccessStatus = $this->afPermManager
147                        ->canViewProtectedVariables( $performer, $usedVariables );
148                    if ( !$protectedVariableAccessStatus->isGood() ) {
149                        if ( $protectedVariableAccessStatus->getBlock() ) {
150                            $this->dieWithError( 'apierror-blocked', 'blocked' );
151                        }
152                        if ( $protectedVariableAccessStatus->getPermission() ) {
153                            $this->dieWithError(
154                                [
155                                    'apierror-permissiondenied',
156                                    $this->msg( "action-{$protectedVariableAccessStatus->getPermission()}" )->plain()
157                                ],
158                                'permissiondenied'
159                            );
160                        }
161
162                        $this->dieStatus( $protectedVariableAccessStatus );
163                    }
164                }
165            }
166
167            if ( $foundInvalid ) {
168                // @todo Tell what the invalid IDs are
169                $this->addWarning( 'abusefilter-log-invalid-filter' );
170            }
171        }
172
173        $result = $this->getResult();
174
175        $this->addTables( 'abuse_filter_log' );
176        $this->addFields( 'afl_timestamp' );
177        $this->addFields( 'afl_rev_id' );
178        $this->addFields( 'afl_deleted' );
179        $this->addFields( 'afl_filter_id' );
180        $this->addFields( 'afl_global' );
181        $this->addFields( 'afl_ip_hex' );
182        $this->addFieldsIf( 'afl_id', $fld_ids );
183        $this->addFieldsIf( 'afl_user_text', $fld_user );
184        $this->addFieldsIf( [ 'afl_namespace', 'afl_title' ], $fld_title );
185        $this->addFieldsIf( 'afl_action', $fld_action );
186        $this->addFieldsIf( 'afl_var_dump', $fld_details );
187        $this->addFieldsIf( 'afl_actions', $fld_result );
188        $this->addFieldsIf( 'afl_wiki', $fld_wiki );
189
190        $this->addOption( 'LIMIT', $params['limit'] + 1 );
191
192        $this->addWhereIf( [ 'afl_id' => $params['logid'] ], isset( $params['logid'] ) );
193
194        $this->addWhereRange( 'afl_timestamp', $params['dir'], $params['start'], $params['end'] );
195
196        if ( isset( $params['user'] ) ) {
197            $u = $this->userFactory->newFromName( $params['user'] );
198            if ( $u ) {
199                // Username normalisation
200                $params['user'] = $u->getName();
201                $userId = $u->getId();
202            } elseif ( IPUtils::isIPAddress( $params['user'] ) ) {
203                // It's an IP, sanitize it
204                $params['user'] = IPUtils::sanitizeIP( $params['user'] );
205                $userId = 0;
206            }
207
208            if ( isset( $userId ) ) {
209                // Only add the WHERE for user in case it's either a valid user
210                // (but not necessary an existing one) or an IP.
211                $this->addWhere(
212                    [
213                        'afl_user' => $userId,
214                        'afl_user_text' => $params['user']
215                    ]
216                );
217            }
218        }
219
220        $this->addWhereIf( [ 'afl_deleted' => 0 ], !$this->afPermManager->canSeeHiddenLogEntries( $performer ) );
221
222        if ( $searchFilters ) {
223            // @todo Avoid code duplication with SpecialAbuseLog::showList
224            $filterConds = [ 'local' => [], 'global' => [] ];
225            foreach ( $searchFilters as $filter ) {
226                $isGlobal = $filter[1];
227                $key = $isGlobal ? 'global' : 'local';
228                $filterConds[$key][] = $filter[0];
229            }
230            $dbr = $this->getDB();
231            $conds = [];
232            if ( $filterConds['local'] ) {
233                $conds[] = $dbr->andExpr( [
234                    'afl_global' => 0,
235                    // @phan-suppress-previous-line PhanTypeMismatchArgument Array is non-empty
236                    'afl_filter_id' => $filterConds['local'],
237                ] );
238            }
239            if ( $filterConds['global'] ) {
240                $conds[] = $dbr->andExpr( [
241                    'afl_global' => 1,
242                    // @phan-suppress-previous-line PhanTypeMismatchArgument Array is non-empty
243                    'afl_filter_id' => $filterConds['global'],
244                ] );
245            }
246            $this->addWhere( $dbr->orExpr( $conds ) );
247        }
248
249        if ( isset( $params['wiki'] ) ) {
250            // 'wiki' won't be set if $wgAbuseFilterIsCentral = false
251            $this->addWhereIf( [ 'afl_wiki' => $params['wiki'] ], $isCentral );
252        }
253
254        $title = $params['title'];
255        if ( $title !== null ) {
256            $titleObj = Title::newFromText( $title );
257            if ( $titleObj === null ) {
258                $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $title ) ] );
259            }
260            $this->addWhereFld( 'afl_namespace', $titleObj->getNamespace() );
261            $this->addWhereFld( 'afl_title', $titleObj->getDBkey() );
262        }
263        $res = $this->select( __METHOD__ );
264
265        $count = 0;
266        foreach ( $res as $row ) {
267            if ( ++$count > $params['limit'] ) {
268                // We've had enough
269                $ts = new MWTimestamp( $row->afl_timestamp );
270                $this->setContinueEnumParameter( 'start', $ts->getTimestamp( TS_ISO_8601 ) );
271                break;
272            }
273            $visibility = SpecialAbuseLog::getEntryVisibilityForUser( $row, $performer, $this->afPermManager );
274            if ( $visibility !== SpecialAbuseLog::VISIBILITY_VISIBLE ) {
275                continue;
276            }
277
278            $filterID = $row->afl_filter_id;
279            $global = $row->afl_global;
280            $fullName = GlobalNameUtils::buildGlobalName( $filterID, $global );
281            $filterObj = $lookup->getFilter( $filterID, $global );
282            $canSeeDetails = $this->afPermManager->canSeeLogDetailsForFilter( $performer, $filterObj );
283
284            $entry = [];
285            if ( $fld_ids ) {
286                $entry['id'] = intval( $row->afl_id );
287                $entry['filter_id'] = $canSeeDetails ? $fullName : '';
288            }
289            if ( $fld_filter ) {
290                $entry['filter'] = $filterObj->getName();
291            }
292            if ( $fld_user ) {
293                $entry['user'] = $row->afl_user_text;
294            }
295            if ( $fld_wiki ) {
296                $entry['wiki'] = $row->afl_wiki;
297            }
298            if ( $fld_title ) {
299                $title = Title::makeTitle( $row->afl_namespace, $row->afl_title );
300                ApiQueryBase::addTitleInfo( $entry, $title );
301            }
302            if ( $fld_action ) {
303                $entry['action'] = $row->afl_action;
304            }
305            if ( $fld_result ) {
306                $entry['result'] = $row->afl_actions;
307            }
308            if ( $fld_revid && $row->afl_rev_id !== null ) {
309                $entry['revid'] = $canSeeDetails ? (int)$row->afl_rev_id : '';
310            }
311            if ( $fld_timestamp ) {
312                $ts = new MWTimestamp( $row->afl_timestamp );
313                $entry['timestamp'] = $ts->getTimestamp( TS_ISO_8601 );
314            }
315            if ( $fld_details ) {
316                $entry['details'] = [];
317                if ( $canSeeDetails ) {
318                    $vars = $this->afVariablesBlobStore->loadVarDump( $row );
319                    $varManager = $this->afVariablesManager;
320                    $entry['details'] = $varManager->exportAllVars( $vars );
321
322                    $usedProtectedVars = $this->afPermManager
323                        ->getUsedProtectedVariables( array_keys( $entry['details'] ) );
324                    if ( $usedProtectedVars ) {
325                        // Unset the variable if the user can't see protected variables.
326                        // Additionally, a protected variable is considered used if the key exists
327                        // but since it can have a null value, check isset before logging access
328                        $protectedVariableValuesShown = [];
329                        foreach ( $usedProtectedVars as $protectedVariable ) {
330                            if ( isset( $entry['details'][$protectedVariable] ) ) {
331                                if ( $this->afPermManager->canViewProtectedVariables(
332                                    $performer, [ $protectedVariable ]
333                                )->isGood() ) {
334                                    $protectedVariableValuesShown[] = $protectedVariable;
335                                } else {
336                                    $entry['details'][$protectedVariable] = '';
337                                }
338                            }
339                        }
340
341                        if ( $filterObj->isProtected() ) {
342                            // user_name or accountname should always exist -- just in case
343                            // if it doesn't, unset the protected variables since they shouldn't be accessed if
344                            // the access isn't logged
345                            if ( isset( $entry['details']['user_name'] ) ||
346                                isset( $entry['details']['accountname'] )
347                            ) {
348                                $logger = $this->abuseLoggerFactory->getProtectedVarsAccessLogger();
349                                $logger->logViewProtectedVariableValue(
350                                    $performer->getUser(),
351                                    $entry['details']['user_name'] ?? $entry['details']['accountname'],
352                                    $protectedVariableValuesShown
353                                );
354                            } else {
355                                foreach ( $usedProtectedVars as $protectedVariable ) {
356                                    if ( isset( $entry['details'][$protectedVariable] ) ) {
357                                        $entry['details'][$protectedVariable] = '';
358                                    }
359                                }
360                            }
361
362                        }
363                    }
364                }
365            }
366
367            if ( $fld_hidden ) {
368                $entry['hidden'] = (bool)$row->afl_deleted;
369            }
370
371            if ( $entry ) {
372                $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $entry );
373                if ( !$fit ) {
374                    $ts = new MWTimestamp( $row->afl_timestamp );
375                    $this->setContinueEnumParameter( 'start', $ts->getTimestamp( TS_ISO_8601 ) );
376                    break;
377                }
378            }
379        }
380        $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'item' );
381    }
382
383    /**
384     * @codeCoverageIgnore Merely declarative
385     * @inheritDoc
386     */
387    public function getAllowedParams() {
388        $params = [
389            'logid' => [
390                ParamValidator::PARAM_TYPE => 'integer'
391            ],
392            'start' => [
393                ParamValidator::PARAM_TYPE => 'timestamp'
394            ],
395            'end' => [
396                ParamValidator::PARAM_TYPE => 'timestamp'
397            ],
398            'dir' => [
399                ParamValidator::PARAM_TYPE => [
400                    'newer',
401                    'older'
402                ],
403                ParamValidator::PARAM_DEFAULT => 'older',
404                ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
405            ],
406            'user' => null,
407            'title' => null,
408            'filter' => [
409                ParamValidator::PARAM_TYPE => 'string',
410                ParamValidator::PARAM_ISMULTI => true,
411                ApiBase::PARAM_HELP_MSG => [
412                    'apihelp-query+abuselog-param-filter',
413                    GlobalNameUtils::GLOBAL_FILTER_PREFIX
414                ]
415            ],
416            'limit' => [
417                ParamValidator::PARAM_DEFAULT => 10,
418                ParamValidator::PARAM_TYPE => 'limit',
419                IntegerDef::PARAM_MIN => 1,
420                IntegerDef::PARAM_MAX => ApiBase::LIMIT_BIG1,
421                IntegerDef::PARAM_MAX2 => ApiBase::LIMIT_BIG2
422            ],
423            'prop' => [
424                ParamValidator::PARAM_DEFAULT => 'ids|user|title|action|result|timestamp|hidden|revid',
425                ParamValidator::PARAM_TYPE => [
426                    'ids',
427                    'filter',
428                    'user',
429                    'title',
430                    'action',
431                    'details',
432                    'result',
433                    'timestamp',
434                    'hidden',
435                    'revid',
436                ],
437                ParamValidator::PARAM_ISMULTI => true
438            ]
439        ];
440        if ( $this->getConfig()->get( 'AbuseFilterIsCentral' ) ) {
441            $params['wiki'] = [
442                ParamValidator::PARAM_TYPE => 'string',
443            ];
444            $params['prop'][ParamValidator::PARAM_DEFAULT] .= '|wiki';
445            $params['prop'][ParamValidator::PARAM_TYPE][] = 'wiki';
446            $params['filter'][ApiBase::PARAM_HELP_MSG] = 'apihelp-query+abuselog-param-filter-central';
447        }
448        return $params;
449    }
450
451    /**
452     * @codeCoverageIgnore Merely declarative
453     * @inheritDoc
454     */
455    protected function getExamplesMessages() {
456        return [
457            'action=query&list=abuselog'
458                => 'apihelp-query+abuselog-example-1',
459            'action=query&list=abuselog&afltitle=API'
460                => 'apihelp-query+abuselog-example-2',
461        ];
462    }
463}