Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.14% covered (warning)
69.14%
224 / 324
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiQueryLogEvents
69.35% covered (warning)
69.35%
224 / 323
12.50% covered (danger)
12.50%
1 / 8
342.04
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
59.63% covered (warning)
59.63%
96 / 161
0.00% covered (danger)
0.00%
0 / 1
192.36
 extractRowInfo
75.41% covered (warning)
75.41%
46 / 61
0.00% covered (danger)
0.00%
0 / 1
51.19
 getAllowedLogActions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getCacheMode
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
15.55
 getAllowedParams
96.05% covered (success)
96.05%
73 / 76
0.00% covered (danger)
0.00%
0 / 1
3
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Api;
10
11use MediaWiki\ChangeTags\ChangeTagsStore;
12use MediaWiki\CommentFormatter\CommentFormatter;
13use MediaWiki\CommentFormatter\RowCommentFormatter;
14use MediaWiki\CommentStore\CommentStore;
15use MediaWiki\Logging\DatabaseLogEntry;
16use MediaWiki\Logging\LogEventsList;
17use MediaWiki\Logging\LogFormatterFactory;
18use MediaWiki\Logging\LogPage;
19use MediaWiki\MainConfigNames;
20use MediaWiki\ParamValidator\TypeDef\NamespaceDef;
21use MediaWiki\ParamValidator\TypeDef\UserDef;
22use MediaWiki\Storage\NameTableAccessException;
23use MediaWiki\Storage\NameTableStore;
24use MediaWiki\Title\Title;
25use MediaWiki\User\UserNameUtils;
26use Wikimedia\ParamValidator\ParamValidator;
27use Wikimedia\ParamValidator\TypeDef\IntegerDef;
28use Wikimedia\Rdbms\IExpression;
29use Wikimedia\Rdbms\LikeValue;
30use Wikimedia\Timestamp\TimestampFormat as TS;
31
32/**
33 * Query action to List the log events, with optional filtering by various parameters.
34 *
35 * @ingroup API
36 */
37class ApiQueryLogEvents extends ApiQueryBase {
38
39    private CommentStore $commentStore;
40    private CommentFormatter $commentFormatter;
41    private NameTableStore $changeTagDefStore;
42    private ChangeTagsStore $changeTagsStore;
43    private UserNameUtils $userNameUtils;
44    private LogFormatterFactory $logFormatterFactory;
45
46    /** @var string[]|null */
47    private $formattedComments;
48
49    public function __construct(
50        ApiQuery $query,
51        string $moduleName,
52        CommentStore $commentStore,
53        RowCommentFormatter $commentFormatter,
54        NameTableStore $changeTagDefStore,
55        ChangeTagsStore $changeTagsStore,
56        UserNameUtils $userNameUtils,
57        LogFormatterFactory $logFormatterFactory
58    ) {
59        parent::__construct( $query, $moduleName, 'le' );
60        $this->commentStore = $commentStore;
61        $this->commentFormatter = $commentFormatter;
62        $this->changeTagDefStore = $changeTagDefStore;
63        $this->changeTagsStore = $changeTagsStore;
64        $this->userNameUtils = $userNameUtils;
65        $this->logFormatterFactory = $logFormatterFactory;
66    }
67
68    private bool $fld_ids = false;
69    private bool $fld_title = false;
70    private bool $fld_type = false;
71    private bool $fld_user = false;
72    private bool $fld_userid = false;
73    private bool $fld_timestamp = false;
74    private bool $fld_comment = false;
75    private bool $fld_parsedcomment = false;
76    private bool $fld_details = false;
77    private bool $fld_tags = false;
78
79    public function execute() {
80        $params = $this->extractRequestParams();
81        $db = $this->getDB();
82        $this->requireMaxOneParameter( $params, 'title', 'prefix', 'namespace' );
83
84        $prop = array_fill_keys( $params['prop'], true );
85
86        $this->fld_ids = isset( $prop['ids'] );
87        $this->fld_title = isset( $prop['title'] );
88        $this->fld_type = isset( $prop['type'] );
89        $this->fld_user = isset( $prop['user'] );
90        $this->fld_userid = isset( $prop['userid'] );
91        $this->fld_timestamp = isset( $prop['timestamp'] );
92        $this->fld_comment = isset( $prop['comment'] );
93        $this->fld_parsedcomment = isset( $prop['parsedcomment'] );
94        $this->fld_details = isset( $prop['details'] );
95        $this->fld_tags = isset( $prop['tags'] );
96
97        $hideLogs = LogEventsList::getExcludeClause( $db, 'user', $this->getAuthority() );
98        if ( $hideLogs !== false ) {
99            $this->addWhere( $hideLogs );
100        }
101
102        $this->addTables( 'logging' );
103
104        $this->addFields( [
105            'log_id',
106            'log_type',
107            'log_action',
108            'log_timestamp',
109            'log_deleted',
110        ] );
111
112        if ( $params['ids'] ) {
113            $this->addWhereIDsFld( 'logging', 'log_id', $params['ids'] );
114        }
115
116        $user = $params['user'];
117        if ( $this->fld_user || $this->fld_userid || $user !== null ) {
118            $this->addTables( 'actor' );
119            $this->addJoinConds( [
120                'actor' => [ 'JOIN', 'actor_id=log_actor' ],
121            ] );
122            $this->addFieldsIf( [ 'actor_name', 'actor_user' ], $this->fld_user );
123            $this->addFieldsIf( 'actor_user', $this->fld_userid );
124            if ( $user !== null ) {
125                $this->addWhereFld( 'actor_name', $user );
126            }
127        }
128
129        if ( $this->fld_ids ) {
130            $this->addTables( 'page' );
131            $this->addJoinConds( [
132                'page' => [ 'LEFT JOIN',
133                    [ 'log_namespace=page_namespace',
134                        'log_title=page_title' ] ]
135            ] );
136            // log_page is the page_id saved at log time, whereas page_id is from a
137            // join at query time.  This leads to different results in various
138            // scenarios, e.g. deletion, recreation.
139            $this->addFields( [ 'page_id', 'log_page' ] );
140        }
141        $this->addFieldsIf(
142            [ 'log_namespace', 'log_title' ],
143            $this->fld_title || $this->fld_parsedcomment
144        );
145        $this->addFieldsIf( 'log_params', $this->fld_details || $this->fld_ids );
146
147        if ( $this->fld_comment || $this->fld_parsedcomment ) {
148            $commentQuery = $this->commentStore->getJoin( 'log_comment' );
149            $this->addTables( $commentQuery['tables'] );
150            $this->addFields( $commentQuery['fields'] );
151            $this->addJoinConds( $commentQuery['joins'] );
152        }
153
154        if ( $this->fld_tags ) {
155            $this->addFields( [
156                'ts_tags' => $this->changeTagsStore->makeTagSummarySubquery( 'logging' )
157            ] );
158        }
159
160        if ( $params['tag'] !== null ) {
161            $this->addTables( 'change_tag' );
162            $this->addJoinConds( [ 'change_tag' => [ 'JOIN',
163                [ 'log_id=ct_log_id' ] ] ] );
164            try {
165                $this->addWhereFld( 'ct_tag_id', $this->changeTagDefStore->getId( $params['tag'] ) );
166            } catch ( NameTableAccessException ) {
167                // Return nothing.
168                $this->addWhere( '1=0' );
169            }
170        }
171
172        if ( $params['action'] !== null ) {
173            // Do validation of action param, list of allowed actions can contains wildcards
174            // Allow the param, when the actions is in the list or a wildcard version is listed.
175            $logAction = $params['action'];
176            if ( !str_contains( $logAction, '/' ) ) {
177                // all items in the list have a slash
178                $valid = false;
179            } else {
180                $logActions = array_fill_keys( $this->getAllowedLogActions(), true );
181                [ $type, $action ] = explode( '/', $logAction, 2 );
182                $valid = isset( $logActions[$logAction] ) || isset( $logActions[$type . '/*'] );
183            }
184
185            if ( !$valid ) {
186                $encParamName = $this->encodeParamName( 'action' );
187                $this->dieWithError(
188                    [ 'apierror-unrecognizedvalue', $encParamName, wfEscapeWikiText( $logAction ) ],
189                    "unknown_$encParamName"
190                );
191            }
192
193            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable,PhanPossiblyUndeclaredVariable T240141
194            $this->addWhereFld( 'log_type', $type );
195            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable,PhanPossiblyUndeclaredVariable T240141
196            $this->addWhereFld( 'log_action', $action );
197        } elseif ( $params['type'] !== null ) {
198            $this->addWhereFld( 'log_type', $params['type'] );
199        }
200
201        $this->addTimestampWhereRange(
202            'log_timestamp',
203            $params['dir'],
204            $params['start'],
205            $params['end']
206        );
207        // Include in ORDER BY for uniqueness
208        $this->addWhereRange( 'log_id', $params['dir'], null, null );
209
210        if ( $params['continue'] !== null ) {
211            $cont = $this->parseContinueParamOrDie( $params['continue'], [ 'timestamp', 'int' ] );
212            $op = ( $params['dir'] === 'newer' ? '>=' : '<=' );
213            $this->addWhere( $db->buildComparison( $op, [
214                'log_timestamp' => $db->timestamp( $cont[0] ),
215                'log_id' => $cont[1],
216            ] ) );
217        }
218
219        $limit = $params['limit'];
220        $this->addOption( 'LIMIT', $limit + 1 );
221
222        $title = $params['title'];
223        if ( $title !== null ) {
224            $titleObj = Title::newFromText( $title );
225            if ( $titleObj === null || $titleObj->isExternal() ) {
226                $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $title ) ] );
227            }
228            $this->addWhereFld( 'log_namespace', $titleObj->getNamespace() );
229            $this->addWhereFld( 'log_title', $titleObj->getDBkey() );
230        }
231
232        if ( $params['namespace'] !== null ) {
233            $this->addWhereFld( 'log_namespace', $params['namespace'] );
234        }
235
236        $prefix = $params['prefix'];
237
238        if ( $prefix !== null ) {
239            if ( $this->getConfig()->get( MainConfigNames::MiserMode ) ) {
240                $this->dieWithError( 'apierror-prefixsearchdisabled' );
241            }
242
243            $title = Title::newFromText( $prefix );
244            if ( $title === null || $title->isExternal() ) {
245                $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $prefix ) ] );
246            }
247            $this->addWhereFld( 'log_namespace', $title->getNamespace() );
248            $this->addWhere(
249                $db->expr( 'log_title', IExpression::LIKE, new LikeValue( $title->getDBkey(), $db->anyString() ) )
250            );
251        }
252
253        // Paranoia: avoid brute force searches (T19342)
254        if ( $params['namespace'] !== null || $title !== null || $user !== null ) {
255            if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
256                $titleBits = LogPage::DELETED_ACTION;
257                $userBits = LogPage::DELETED_USER;
258            } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
259                $titleBits = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
260                $userBits = LogPage::DELETED_USER | LogPage::DELETED_RESTRICTED;
261            } else {
262                $titleBits = 0;
263                $userBits = 0;
264            }
265            if ( ( $params['namespace'] !== null || $title !== null ) && $titleBits ) {
266                $this->addWhere( $db->bitAnd( 'log_deleted', $titleBits ) . " != $titleBits" );
267            }
268            if ( $user !== null && $userBits ) {
269                $this->addWhere( $db->bitAnd( 'log_deleted', $userBits ) . " != $userBits" );
270            }
271        }
272
273        // T220999: MySQL/MariaDB (10.1.37) can sometimes irrationally decide that querying `actor` before
274        // `logging` and filesorting is somehow better than querying $limit+1 rows from `logging`.
275        // Tell it not to reorder the query. But not when `letag` was used, as it seems as likely
276        // to be harmed as helped in that case.
277        // If "user" was specified, it's obviously correct to query actor first (T282122)
278        if ( $params['tag'] === null && $user === null ) {
279            $this->addOption( 'STRAIGHT_JOIN' );
280        }
281
282        $this->addOption(
283            'MAX_EXECUTION_TIME',
284            $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries )
285        );
286
287        $count = 0;
288        $res = $this->select( __METHOD__ );
289
290        if ( $this->fld_title ) {
291            $this->executeGenderCacheFromResultWrapper( $res, __METHOD__, 'log' );
292        }
293        if ( $this->fld_parsedcomment ) {
294            $this->formattedComments = $this->commentFormatter->formatItems(
295                $this->commentFormatter->rows( $res )
296                    ->commentKey( 'log_comment' )
297                    ->indexField( 'log_id' )
298                    ->namespaceField( 'log_namespace' )
299                    ->titleField( 'log_title' )
300            );
301        }
302
303        $result = $this->getResult();
304        foreach ( $res as $row ) {
305            if ( ++$count > $limit ) {
306                // We've reached the one extra which shows that there are
307                // additional pages to be had. Stop here...
308                $this->setContinueEnumParameter( 'continue', "$row->log_timestamp|$row->log_id" );
309                break;
310            }
311
312            $vals = $this->extractRowInfo( $row );
313            $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $vals );
314            if ( !$fit ) {
315                $this->setContinueEnumParameter( 'continue', "$row->log_timestamp|$row->log_id" );
316                break;
317            }
318        }
319        $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'item' );
320    }
321
322    private function extractRowInfo( \stdClass $row ): array {
323        $logEntry = DatabaseLogEntry::newFromRow( $row );
324        $vals = [
325            ApiResult::META_TYPE => 'assoc',
326        ];
327        $anyHidden = false;
328
329        if ( $this->fld_ids ) {
330            $vals['logid'] = (int)$row->log_id;
331        }
332
333        if ( $this->fld_title ) {
334            $title = Title::makeTitle( $row->log_namespace, $row->log_title );
335        }
336
337        $authority = $this->getAuthority();
338        if ( $this->fld_title || $this->fld_ids || ( $this->fld_details && $row->log_params !== '' ) ) {
339            if ( LogEventsList::isDeleted( $row, LogPage::DELETED_ACTION ) ) {
340                $vals['actionhidden'] = true;
341                $anyHidden = true;
342            }
343            if ( LogEventsList::userCan( $row, LogPage::DELETED_ACTION, $authority ) ) {
344                if ( $this->fld_title ) {
345                    // @phan-suppress-next-next-line PhanTypeMismatchArgumentNullable,PhanPossiblyUndeclaredVariable
346                    // title is set when used
347                    ApiQueryBase::addTitleInfo( $vals, $title );
348                }
349                if ( $this->fld_ids ) {
350                    $vals['pageid'] = (int)$row->page_id;
351                    $vals['logpage'] = (int)$row->log_page;
352                    $revId = $logEntry->getAssociatedRevId();
353                    if ( $revId ) {
354                        $vals['revid'] = (int)$revId;
355                    }
356                }
357                if ( $this->fld_details ) {
358                    $vals['params'] = $this->logFormatterFactory->newFromEntry( $logEntry )->formatParametersForApi();
359                }
360            }
361        }
362
363        if ( $this->fld_type ) {
364            $vals['type'] = $row->log_type;
365            $vals['action'] = $row->log_action;
366        }
367
368        if ( $this->fld_user || $this->fld_userid ) {
369            if ( LogEventsList::isDeleted( $row, LogPage::DELETED_USER ) ) {
370                $vals['userhidden'] = true;
371                $anyHidden = true;
372            }
373            if ( LogEventsList::userCan( $row, LogPage::DELETED_USER, $authority ) ) {
374                if ( $this->fld_user ) {
375                    $vals['user'] = $row->actor_name;
376                }
377                if ( $this->fld_userid ) {
378                    $vals['userid'] = (int)$row->actor_user;
379                }
380
381                if ( isset( $vals['user'] ) && $this->userNameUtils->isTemp( $vals['user'] ) ) {
382                    $vals['temp'] = true;
383                }
384
385                if ( !$row->actor_user ) {
386                    $vals['anon'] = true;
387                }
388            }
389        }
390        if ( $this->fld_timestamp ) {
391            $vals['timestamp'] = wfTimestamp( TS::ISO_8601, $row->log_timestamp );
392        }
393
394        if ( $this->fld_comment || $this->fld_parsedcomment ) {
395            if ( LogEventsList::isDeleted( $row, LogPage::DELETED_COMMENT ) ) {
396                $vals['commenthidden'] = true;
397                $anyHidden = true;
398            }
399            if ( LogEventsList::userCan( $row, LogPage::DELETED_COMMENT, $authority ) ) {
400                if ( $this->fld_comment ) {
401                    $vals['comment'] = $this->commentStore->getComment( 'log_comment', $row )->text;
402                }
403
404                if ( $this->fld_parsedcomment ) {
405                    // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
406                    $vals['parsedcomment'] = $this->formattedComments[$row->log_id];
407                }
408            }
409        }
410
411        if ( $this->fld_tags ) {
412            if ( $row->ts_tags ) {
413                $tags = explode( ',', $row->ts_tags );
414                ApiResult::setIndexedTagName( $tags, 'tag' );
415                $vals['tags'] = $tags;
416            } else {
417                $vals['tags'] = [];
418            }
419        }
420
421        if ( $anyHidden && LogEventsList::isDeleted( $row, LogPage::DELETED_RESTRICTED ) ) {
422            $vals['suppressed'] = true;
423        }
424
425        return $vals;
426    }
427
428    /**
429     * @return array
430     */
431    private function getAllowedLogActions() {
432        $config = $this->getConfig();
433        return array_keys( array_merge(
434            $config->get( MainConfigNames::LogActions ),
435            $config->get( MainConfigNames::LogActionsHandlers )
436        ) );
437    }
438
439    /** @inheritDoc */
440    public function getCacheMode( $params ) {
441        if ( $this->userCanSeeRevDel() ) {
442            return 'private';
443        }
444        if ( $params['prop'] !== null && in_array( 'parsedcomment', $params['prop'] ) ) {
445            // MediaWiki\CommentFormatter\CommentFormatter::formatItems() calls wfMessage() among other things
446            return 'anon-public-user-private';
447        } elseif ( LogEventsList::getExcludeClause( $this->getDB(), 'user', $this->getAuthority() )
448            === LogEventsList::getExcludeClause( $this->getDB(), 'public' )
449        ) { // Output can only contain public data.
450            return 'public';
451        } else {
452            return 'anon-public-user-private';
453        }
454    }
455
456    /** @inheritDoc */
457    public function getAllowedParams( $flags = 0 ) {
458        $config = $this->getConfig();
459        if ( $flags & ApiBase::GET_VALUES_FOR_HELP ) {
460            $logActions = $this->getAllowedLogActions();
461            sort( $logActions );
462        } else {
463            $logActions = null;
464        }
465        $ret = [
466            'prop' => [
467                ParamValidator::PARAM_ISMULTI => true,
468                ParamValidator::PARAM_DEFAULT => 'ids|title|type|user|timestamp|comment|details',
469                ParamValidator::PARAM_TYPE => [
470                    'ids',
471                    'title',
472                    'type',
473                    'user',
474                    'userid',
475                    'timestamp',
476                    'comment',
477                    'parsedcomment',
478                    'details',
479                    'tags'
480                ],
481                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
482            ],
483            'type' => [
484                ParamValidator::PARAM_TYPE => LogPage::validTypes(),
485            ],
486            'action' => [
487                // validation on request is done in execute()
488                ParamValidator::PARAM_TYPE => $logActions
489            ],
490            'start' => [
491                ParamValidator::PARAM_TYPE => 'timestamp'
492            ],
493            'end' => [
494                ParamValidator::PARAM_TYPE => 'timestamp'
495            ],
496            'dir' => [
497                ParamValidator::PARAM_DEFAULT => 'older',
498                ParamValidator::PARAM_TYPE => [
499                    'newer',
500                    'older'
501                ],
502                ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
503                ApiBase::PARAM_HELP_MSG_PER_VALUE => [
504                    'newer' => 'api-help-paramvalue-direction-newer',
505                    'older' => 'api-help-paramvalue-direction-older',
506                ],
507            ],
508            'ids' => [
509                ParamValidator::PARAM_TYPE => 'integer',
510                ParamValidator::PARAM_ISMULTI => true
511            ],
512            'user' => [
513                ParamValidator::PARAM_TYPE => 'user',
514                UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name', 'ip', 'temp', 'id', 'interwiki' ],
515            ],
516            'title' => null,
517            'namespace' => [
518                ParamValidator::PARAM_TYPE => 'namespace',
519                NamespaceDef::PARAM_EXTRA_NAMESPACES => [ NS_MEDIA, NS_SPECIAL ],
520            ],
521            'prefix' => [],
522            'tag' => null,
523            'limit' => [
524                ParamValidator::PARAM_DEFAULT => 10,
525                ParamValidator::PARAM_TYPE => 'limit',
526                IntegerDef::PARAM_MIN => 1,
527                IntegerDef::PARAM_MAX => ApiBase::LIMIT_BIG1,
528                IntegerDef::PARAM_MAX2 => ApiBase::LIMIT_BIG2
529            ],
530            'continue' => [
531                ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
532            ],
533        ];
534
535        if ( $config->get( MainConfigNames::MiserMode ) ) {
536            $ret['prefix'][ApiBase::PARAM_HELP_MSG] = 'api-help-param-disabled-in-miser-mode';
537        }
538
539        return $ret;
540    }
541
542    /** @inheritDoc */
543    protected function getExamplesMessages() {
544        return [
545            'action=query&list=logevents'
546                => 'apihelp-query+logevents-example-simple',
547        ];
548    }
549
550    /** @inheritDoc */
551    public function getHelpUrls() {
552        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Logevents';
553    }
554}
555
556/** @deprecated class alias since 1.43 */
557class_alias( ApiQueryLogEvents::class, 'ApiQueryLogEvents' );