MediaWiki master
ApiQueryLogEvents.php
Go to the documentation of this file.
1<?php
37
44
45 private CommentStore $commentStore;
46 private CommentFormatter $commentFormatter;
47 private NameTableStore $changeTagDefStore;
48 private UserNameUtils $userNameUtils;
49
51 private $formattedComments;
52
61 public function __construct(
62 ApiQuery $query,
63 $moduleName,
64 CommentStore $commentStore,
65 RowCommentFormatter $commentFormatter,
66 NameTableStore $changeTagDefStore,
67 UserNameUtils $userNameUtils
68 ) {
69 parent::__construct( $query, $moduleName, 'le' );
70 $this->commentStore = $commentStore;
71 $this->commentFormatter = $commentFormatter;
72 $this->changeTagDefStore = $changeTagDefStore;
73 $this->userNameUtils = $userNameUtils;
74 }
75
76 private bool $fld_ids = false;
77 private bool $fld_title = false;
78 private bool $fld_type = false;
79 private bool $fld_user = false;
80 private bool $fld_userid = false;
81 private bool $fld_timestamp = false;
82 private bool $fld_comment = false;
83 private bool $fld_parsedcomment = false;
84 private bool $fld_details = false;
85 private bool $fld_tags = false;
86
87 public function execute() {
89 $db = $this->getDB();
90 $this->requireMaxOneParameter( $params, 'title', 'prefix', 'namespace' );
91
92 $prop = array_fill_keys( $params['prop'], true );
93
94 $this->fld_ids = isset( $prop['ids'] );
95 $this->fld_title = isset( $prop['title'] );
96 $this->fld_type = isset( $prop['type'] );
97 $this->fld_user = isset( $prop['user'] );
98 $this->fld_userid = isset( $prop['userid'] );
99 $this->fld_timestamp = isset( $prop['timestamp'] );
100 $this->fld_comment = isset( $prop['comment'] );
101 $this->fld_parsedcomment = isset( $prop['parsedcomment'] );
102 $this->fld_details = isset( $prop['details'] );
103 $this->fld_tags = isset( $prop['tags'] );
104
105 $hideLogs = LogEventsList::getExcludeClause( $db, 'user', $this->getAuthority() );
106 if ( $hideLogs !== false ) {
107 $this->addWhere( $hideLogs );
108 }
109
110 $this->addTables( 'logging' );
111
112 $this->addFields( [
113 'log_id',
114 'log_type',
115 'log_action',
116 'log_timestamp',
117 'log_deleted',
118 ] );
119
120 $user = $params['user'];
121 if ( $this->fld_user || $this->fld_userid || $user !== null ) {
122 $this->addTables( 'actor' );
123 $this->addJoinConds( [
124 'actor' => [ 'JOIN', 'actor_id=log_actor' ],
125 ] );
126 $this->addFieldsIf( [ 'actor_name', 'actor_user' ], $this->fld_user );
127 $this->addFieldsIf( 'actor_user', $this->fld_userid );
128 if ( $user !== null ) {
129 $this->addWhereFld( 'actor_name', $user );
130 }
131 }
132
133 if ( $this->fld_ids ) {
134 $this->addTables( 'page' );
135 $this->addJoinConds( [
136 'page' => [ 'LEFT JOIN',
137 [ 'log_namespace=page_namespace',
138 'log_title=page_title' ] ]
139 ] );
140 // log_page is the page_id saved at log time, whereas page_id is from a
141 // join at query time. This leads to different results in various
142 // scenarios, e.g. deletion, recreation.
143 $this->addFields( [ 'page_id', 'log_page' ] );
144 }
145 $this->addFieldsIf(
146 [ 'log_namespace', 'log_title' ],
147 $this->fld_title || $this->fld_parsedcomment
148 );
149 $this->addFieldsIf( 'log_params', $this->fld_details || $this->fld_ids );
150
151 if ( $this->fld_comment || $this->fld_parsedcomment ) {
152 $commentQuery = $this->commentStore->getJoin( 'log_comment' );
153 $this->addTables( $commentQuery['tables'] );
154 $this->addFields( $commentQuery['fields'] );
155 $this->addJoinConds( $commentQuery['joins'] );
156 }
157
158 if ( $this->fld_tags ) {
159 $this->addFields( [ 'ts_tags' => ChangeTags::makeTagSummarySubquery( 'logging' ) ] );
160 }
161
162 if ( $params['tag'] !== null ) {
163 $this->addTables( 'change_tag' );
164 $this->addJoinConds( [ 'change_tag' => [ 'JOIN',
165 [ 'log_id=ct_log_id' ] ] ] );
166 try {
167 $this->addWhereFld( 'ct_tag_id', $this->changeTagDefStore->getId( $params['tag'] ) );
168 } catch ( NameTableAccessException $exception ) {
169 // Return nothing.
170 $this->addWhere( '1=0' );
171 }
172 }
173
174 if ( $params['action'] !== null ) {
175 // Do validation of action param, list of allowed actions can contains wildcards
176 // Allow the param, when the actions is in the list or a wildcard version is listed.
177 $logAction = $params['action'];
178 if ( !str_contains( $logAction, '/' ) ) {
179 // all items in the list have a slash
180 $valid = false;
181 } else {
182 $logActions = array_fill_keys( $this->getAllowedLogActions(), true );
183 [ $type, $action ] = explode( '/', $logAction, 2 );
184 $valid = isset( $logActions[$logAction] ) || isset( $logActions[$type . '/*'] );
185 }
186
187 if ( !$valid ) {
188 $encParamName = $this->encodeParamName( 'action' );
189 $this->dieWithError(
190 [ 'apierror-unrecognizedvalue', $encParamName, wfEscapeWikiText( $logAction ) ],
191 "unknown_$encParamName"
192 );
193 }
194
195 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable,PhanPossiblyUndeclaredVariable T240141
196 $this->addWhereFld( 'log_type', $type );
197 // @phan-suppress-next-line PhanTypeMismatchArgumentNullable,PhanPossiblyUndeclaredVariable T240141
198 $this->addWhereFld( 'log_action', $action );
199 } elseif ( $params['type'] !== null ) {
200 $this->addWhereFld( 'log_type', $params['type'] );
201 }
202
204 'log_timestamp',
205 $params['dir'],
206 $params['start'],
207 $params['end']
208 );
209 // Include in ORDER BY for uniqueness
210 $this->addWhereRange( 'log_id', $params['dir'], null, null );
211
212 if ( $params['continue'] !== null ) {
213 $cont = $this->parseContinueParamOrDie( $params['continue'], [ 'timestamp', 'int' ] );
214 $op = ( $params['dir'] === 'newer' ? '>=' : '<=' );
215 $this->addWhere( $db->buildComparison( $op, [
216 'log_timestamp' => $db->timestamp( $cont[0] ),
217 'log_id' => $cont[1],
218 ] ) );
219 }
220
221 $limit = $params['limit'];
222 $this->addOption( 'LIMIT', $limit + 1 );
223
224 $title = $params['title'];
225 if ( $title !== null ) {
226 $titleObj = Title::newFromText( $title );
227 if ( $titleObj === null || $titleObj->isExternal() ) {
228 $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $title ) ] );
229 }
230 $this->addWhereFld( 'log_namespace', $titleObj->getNamespace() );
231 $this->addWhereFld( 'log_title', $titleObj->getDBkey() );
232 }
233
234 if ( $params['namespace'] !== null ) {
235 $this->addWhereFld( 'log_namespace', $params['namespace'] );
236 }
237
238 $prefix = $params['prefix'];
239
240 if ( $prefix !== null ) {
241 if ( $this->getConfig()->get( MainConfigNames::MiserMode ) ) {
242 $this->dieWithError( 'apierror-prefixsearchdisabled' );
243 }
244
245 $title = Title::newFromText( $prefix );
246 if ( $title === null || $title->isExternal() ) {
247 $this->dieWithError( [ 'apierror-invalidtitle', wfEscapeWikiText( $prefix ) ] );
248 }
249 $this->addWhereFld( 'log_namespace', $title->getNamespace() );
250 $this->addWhere(
251 $db->expr( 'log_title', IExpression::LIKE, new LikeValue( $title->getDBkey(), $db->anyString() ) )
252 );
253 }
254
255 // Paranoia: avoid brute force searches (T19342)
256 if ( $params['namespace'] !== null || $title !== null || $user !== null ) {
257 if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
258 $titleBits = LogPage::DELETED_ACTION;
259 $userBits = LogPage::DELETED_USER;
260 } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
261 $titleBits = LogPage::DELETED_ACTION | LogPage::DELETED_RESTRICTED;
262 $userBits = LogPage::DELETED_USER | LogPage::DELETED_RESTRICTED;
263 } else {
264 $titleBits = 0;
265 $userBits = 0;
266 }
267 if ( ( $params['namespace'] !== null || $title !== null ) && $titleBits ) {
268 $this->addWhere( $db->bitAnd( 'log_deleted', $titleBits ) . " != $titleBits" );
269 }
270 if ( $user !== null && $userBits ) {
271 $this->addWhere( $db->bitAnd( 'log_deleted', $userBits ) . " != $userBits" );
272 }
273 }
274
275 // T220999: MySQL/MariaDB (10.1.37) can sometimes irrationally decide that querying `actor` before
276 // `logging` and filesorting is somehow better than querying $limit+1 rows from `logging`.
277 // Tell it not to reorder the query. But not when `letag` was used, as it seems as likely
278 // to be harmed as helped in that case.
279 // If "user" was specified, it's obviously correct to query actor first (T282122)
280 if ( $params['tag'] === null && $user === null ) {
281 $this->addOption( 'STRAIGHT_JOIN' );
282 }
283
284 $this->addOption(
285 'MAX_EXECUTION_TIME',
286 $this->getConfig()->get( MainConfigNames::MaxExecutionTimeForExpensiveQueries )
287 );
288
289 $count = 0;
290 $res = $this->select( __METHOD__ );
291
292 if ( $this->fld_title ) {
293 $this->executeGenderCacheFromResultWrapper( $res, __METHOD__, 'log' );
294 }
295 if ( $this->fld_parsedcomment ) {
296 $this->formattedComments = $this->commentFormatter->formatItems(
297 $this->commentFormatter->rows( $res )
298 ->commentKey( 'log_comment' )
299 ->indexField( 'log_id' )
300 ->namespaceField( 'log_namespace' )
301 ->titleField( 'log_title' )
302 );
303 }
304
305 $result = $this->getResult();
306 foreach ( $res as $row ) {
307 if ( ++$count > $limit ) {
308 // We've reached the one extra which shows that there are
309 // additional pages to be had. Stop here...
310 $this->setContinueEnumParameter( 'continue', "$row->log_timestamp|$row->log_id" );
311 break;
312 }
313
314 $vals = $this->extractRowInfo( $row );
315 $fit = $result->addValue( [ 'query', $this->getModuleName() ], null, $vals );
316 if ( !$fit ) {
317 $this->setContinueEnumParameter( 'continue', "$row->log_timestamp|$row->log_id" );
318 break;
319 }
320 }
321 $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'item' );
322 }
323
324 private function extractRowInfo( $row ) {
325 $logEntry = DatabaseLogEntry::newFromRow( $row );
326 $vals = [
327 ApiResult::META_TYPE => 'assoc',
328 ];
329 $anyHidden = false;
330
331 if ( $this->fld_ids ) {
332 $vals['logid'] = (int)$row->log_id;
333 }
334
335 if ( $this->fld_title ) {
336 $title = Title::makeTitle( $row->log_namespace, $row->log_title );
337 }
338
339 $authority = $this->getAuthority();
340 if ( $this->fld_title || $this->fld_ids || ( $this->fld_details && $row->log_params !== '' ) ) {
341 if ( LogEventsList::isDeleted( $row, LogPage::DELETED_ACTION ) ) {
342 $vals['actionhidden'] = true;
343 $anyHidden = true;
344 }
345 if ( LogEventsList::userCan( $row, LogPage::DELETED_ACTION, $authority ) ) {
346 if ( $this->fld_title ) {
347 // @phan-suppress-next-next-line PhanTypeMismatchArgumentNullable,PhanPossiblyUndeclaredVariable
348 // title is set when used
349 ApiQueryBase::addTitleInfo( $vals, $title );
350 }
351 if ( $this->fld_ids ) {
352 $vals['pageid'] = (int)$row->page_id;
353 $vals['logpage'] = (int)$row->log_page;
354 $revId = $logEntry->getAssociatedRevId();
355 if ( $revId ) {
356 $vals['revid'] = (int)$revId;
357 }
358 }
359 if ( $this->fld_details ) {
360 $vals['params'] = LogFormatter::newFromEntry( $logEntry )->formatParametersForApi();
361 }
362 }
363 }
364
365 if ( $this->fld_type ) {
366 $vals['type'] = $row->log_type;
367 $vals['action'] = $row->log_action;
368 }
369
370 if ( $this->fld_user || $this->fld_userid ) {
371 if ( LogEventsList::isDeleted( $row, LogPage::DELETED_USER ) ) {
372 $vals['userhidden'] = true;
373 $anyHidden = true;
374 }
375 if ( LogEventsList::userCan( $row, LogPage::DELETED_USER, $authority ) ) {
376 if ( $this->fld_user ) {
377 $vals['user'] = $row->actor_name;
378 }
379 if ( $this->fld_userid ) {
380 $vals['userid'] = (int)$row->actor_user;
381 }
382
383 if ( isset( $vals['user'] ) && $this->userNameUtils->isTemp( $vals['user'] ) ) {
384 $vals['temp'] = true;
385 }
386
387 if ( !$row->actor_user ) {
388 $vals['anon'] = true;
389 }
390 }
391 }
392 if ( $this->fld_timestamp ) {
393 $vals['timestamp'] = wfTimestamp( TS_ISO_8601, $row->log_timestamp );
394 }
395
396 if ( $this->fld_comment || $this->fld_parsedcomment ) {
397 if ( LogEventsList::isDeleted( $row, LogPage::DELETED_COMMENT ) ) {
398 $vals['commenthidden'] = true;
399 $anyHidden = true;
400 }
401 if ( LogEventsList::userCan( $row, LogPage::DELETED_COMMENT, $authority ) ) {
402 if ( $this->fld_comment ) {
403 $vals['comment'] = $this->commentStore->getComment( 'log_comment', $row )->text;
404 }
405
406 if ( $this->fld_parsedcomment ) {
407 // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
408 $vals['parsedcomment'] = $this->formattedComments[$row->log_id];
409 }
410 }
411 }
412
413 if ( $this->fld_tags ) {
414 if ( $row->ts_tags ) {
415 $tags = explode( ',', $row->ts_tags );
416 ApiResult::setIndexedTagName( $tags, 'tag' );
417 $vals['tags'] = $tags;
418 } else {
419 $vals['tags'] = [];
420 }
421 }
422
423 if ( $anyHidden && LogEventsList::isDeleted( $row, LogPage::DELETED_RESTRICTED ) ) {
424 $vals['suppressed'] = true;
425 }
426
427 return $vals;
428 }
429
433 private function getAllowedLogActions() {
434 $config = $this->getConfig();
435 return array_keys( array_merge(
436 $config->get( MainConfigNames::LogActions ),
437 $config->get( MainConfigNames::LogActionsHandlers )
438 ) );
439 }
440
441 public function getCacheMode( $params ) {
442 if ( $this->userCanSeeRevDel() ) {
443 return 'private';
444 }
445 if ( $params['prop'] !== null && in_array( 'parsedcomment', $params['prop'] ) ) {
446 // MediaWiki\CommentFormatter\CommentFormatter::formatItems() calls wfMessage() among other things
447 return 'anon-public-user-private';
448 } elseif ( LogEventsList::getExcludeClause( $this->getDB(), 'user', $this->getAuthority() )
449 === LogEventsList::getExcludeClause( $this->getDB(), 'public' )
450 ) { // Output can only contain public data.
451 return 'public';
452 } else {
453 return 'anon-public-user-private';
454 }
455 }
456
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 ],
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',
504 'newer' => 'api-help-paramvalue-direction-newer',
505 'older' => 'api-help-paramvalue-direction-older',
506 ],
507 ],
508 'user' => [
509 ParamValidator::PARAM_TYPE => 'user',
510 UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name', 'ip', 'temp', 'id', 'interwiki' ],
511 ],
512 'title' => null,
513 'namespace' => [
514 ParamValidator::PARAM_TYPE => 'namespace',
515 NamespaceDef::PARAM_EXTRA_NAMESPACES => [ NS_MEDIA, NS_SPECIAL ],
516 ],
517 'prefix' => [],
518 'tag' => null,
519 'limit' => [
520 ParamValidator::PARAM_DEFAULT => 10,
521 ParamValidator::PARAM_TYPE => 'limit',
522 IntegerDef::PARAM_MIN => 1,
523 IntegerDef::PARAM_MAX => ApiBase::LIMIT_BIG1,
524 IntegerDef::PARAM_MAX2 => ApiBase::LIMIT_BIG2
525 ],
526 'continue' => [
527 ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
528 ],
529 ];
530
531 if ( $config->get( MainConfigNames::MiserMode ) ) {
532 $ret['prefix'][ApiBase::PARAM_HELP_MSG] = 'api-help-param-disabled-in-miser-mode';
533 }
534
535 return $ret;
536 }
537
538 protected function getExamplesMessages() {
539 return [
540 'action=query&list=logevents'
541 => 'apihelp-query+logevents-example-simple',
542 ];
543 }
544
545 public function getHelpUrls() {
546 return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Logevents';
547 }
548}
const NS_SPECIAL
Definition Defines.php:53
const NS_MEDIA
Definition Defines.php:52
wfEscapeWikiText( $input)
Escapes the given text so that it may be output using addWikiText() without any linking,...
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
array $params
The job parameters.
dieWithError( $msg, $code=null, $data=null, $httpCode=0)
Abort execution with an error.
Definition ApiBase.php:1542
encodeParamName( $paramName)
This method mangles parameter name based on the prefix supplied to the constructor.
Definition ApiBase.php:798
parseContinueParamOrDie(string $continue, array $types)
Parse the 'continue' parameter in the usual format and validate the types of each part,...
Definition ApiBase.php:1734
const PARAM_HELP_MSG_PER_VALUE
((string|array|Message)[]) When PARAM_TYPE is an array, or 'string' with PARAM_ISMULTI,...
Definition ApiBase.php:211
const LIMIT_BIG1
Fast query, standard limit.
Definition ApiBase.php:236
requireMaxOneParameter( $params,... $required)
Dies if more than one parameter from a certain set of parameters are set and not false.
Definition ApiBase.php:994
getResult()
Get the result object.
Definition ApiBase.php:680
extractRequestParams( $options=[])
Using getAllowedParams(), this function makes an array of the values provided by the user,...
Definition ApiBase.php:820
const PARAM_HELP_MSG
(string|array|Message) Specify an alternative i18n documentation message for this parameter.
Definition ApiBase.php:171
const GET_VALUES_FOR_HELP
getAllowedParams() flag: When this is set, the result could take longer to generate,...
Definition ApiBase.php:249
const LIMIT_BIG2
Fast query, apihighlimits limit.
Definition ApiBase.php:238
getModuleName()
Get the name of the module being executed by this instance.
Definition ApiBase.php:541
This is a base class for all Query modules.
static addTitleInfo(&$arr, $title, $prefix='')
Add information (title and namespace) about a Title object to a result array.
setContinueEnumParameter( $paramName, $paramValue)
Set a query-continue value.
addWhereRange( $field, $dir, $start, $end, $sort=true)
Add a WHERE clause corresponding to a range, and an ORDER BY clause to sort in the right direction.
addFields( $value)
Add a set of fields to select to the internal array.
addOption( $name, $value=null)
Add an option such as LIMIT or USE INDEX.
addTables( $tables, $alias=null)
Add a set of tables to the internal array.
addTimestampWhereRange( $field, $dir, $start, $end, $sort=true)
Add a WHERE clause corresponding to a range, similar to addWhereRange, but converts $start and $end t...
getDB()
Get the Query database connection (read-only)
executeGenderCacheFromResultWrapper(IResultWrapper $res, $fname=__METHOD__, $fieldPrefix='page')
Preprocess the result set to fill the GenderCache with the necessary information before using self::a...
select( $method, $extraQuery=[], array &$hookData=null)
Execute a SELECT query based on the values in the internal arrays.
addFieldsIf( $value, $condition)
Same as addFields(), but add the fields only if a condition is met.
addJoinConds( $join_conds)
Add a set of JOIN conditions to the internal array.
addWhereFld( $field, $value)
Equivalent to addWhere( [ $field => $value ] )
addWhere( $value)
Add a set of WHERE clauses to the internal array.
userCanSeeRevDel()
Check whether the current user has permission to view revision-deleted fields.
Query action to List the log events, with optional filtering by various parameters.
getExamplesMessages()
Returns usage examples for this module.
__construct(ApiQuery $query, $moduleName, CommentStore $commentStore, RowCommentFormatter $commentFormatter, NameTableStore $changeTagDefStore, UserNameUtils $userNameUtils)
execute()
Evaluates the parameters, performs the requested query, and sets up the result.
getCacheMode( $params)
Get the cache mode for the data generated by this module.
getHelpUrls()
Return links to more detailed help pages about the module.
This is the main query class.
Definition ApiQuery.php:43
static setIndexedTagName(array &$arr, $tag)
Set the tag name for numeric-keyed values in XML format.
static makeTagSummarySubquery( $tables)
Make the tag summary subquery based on the given tables and return it.
static newFromEntry(LogEntry $entry)
Constructs a new formatter suitable for given entry.
This is the main service interface for converting single-line comments from various DB comment fields...
This is basically a CommentFormatter with a CommentStore dependency, allowing it to retrieve comment ...
Handle database storage of comments such as edit summaries and log reasons.
A class containing constants representing the names of configuration variables.
Type definition for namespace types.
Type definition for user types.
Definition UserDef.php:27
Exception representing a failure to look up a row from a name table.
Represents a title within MediaWiki.
Definition Title.php:78
UserNameUtils service.
Service for formatting and validating API parameters.
Type definition for integer types.
Content of like value.
Definition LikeValue.php:14