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