Completed
Branch master (939199)
by
unknown
39:35
created

includes/api/ApiQueryUserContributions.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 *
4
 *
5
 * Created on Oct 16, 2006
6
 *
7
 * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
8
 *
9
 * This program is free software; you can redistribute it and/or modify
10
 * it under the terms of the GNU General Public License as published by
11
 * the Free Software Foundation; either version 2 of the License, or
12
 * (at your option) any later version.
13
 *
14
 * This program is distributed in the hope that it will be useful,
15
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
 * GNU General Public License for more details.
18
 *
19
 * You should have received a copy of the GNU General Public License along
20
 * with this program; if not, write to the Free Software Foundation, Inc.,
21
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
22
 * http://www.gnu.org/copyleft/gpl.html
23
 *
24
 * @file
25
 */
26
27
/**
28
 * This query action adds a list of a specified user's contributions to the output.
29
 *
30
 * @ingroup API
31
 */
32
class ApiQueryContributions extends ApiQueryBase {
33
34
	public function __construct( ApiQuery $query, $moduleName ) {
35
		parent::__construct( $query, $moduleName, 'uc' );
36
	}
37
38
	private $params, $prefixMode, $userprefix, $multiUserMode, $idMode, $usernames, $userids,
39
		$parentLens;
40
	private $fld_ids = false, $fld_title = false, $fld_timestamp = false,
41
		$fld_comment = false, $fld_parsedcomment = false, $fld_flags = false,
42
		$fld_patrolled = false, $fld_tags = false, $fld_size = false, $fld_sizediff = false;
43
44
	public function execute() {
45
		// Parse some parameters
46
		$this->params = $this->extractRequestParams();
47
48
		$prop = array_flip( $this->params['prop'] );
49
		$this->fld_ids = isset( $prop['ids'] );
50
		$this->fld_title = isset( $prop['title'] );
51
		$this->fld_comment = isset( $prop['comment'] );
52
		$this->fld_parsedcomment = isset( $prop['parsedcomment'] );
53
		$this->fld_size = isset( $prop['size'] );
54
		$this->fld_sizediff = isset( $prop['sizediff'] );
55
		$this->fld_flags = isset( $prop['flags'] );
56
		$this->fld_timestamp = isset( $prop['timestamp'] );
57
		$this->fld_patrolled = isset( $prop['patrolled'] );
58
		$this->fld_tags = isset( $prop['tags'] );
59
60
		// Most of this code will use the 'contributions' group DB, which can map to replica DBs
61
		// with extra user based indexes or partioning by user. The additional metadata
62
		// queries should use a regular replica DB since the lookup pattern is not all by user.
63
		$dbSecondary = $this->getDB(); // any random replica DB
64
65
		// TODO: if the query is going only against the revision table, should this be done?
66
		$this->selectNamedDB( 'contributions', DB_REPLICA, 'contributions' );
67
68
		$this->idMode = false;
69
		if ( isset( $this->params['userprefix'] ) ) {
70
			$this->prefixMode = true;
71
			$this->multiUserMode = true;
72
			$this->userprefix = $this->params['userprefix'];
73
		} else {
74
			$anyIPs = false;
75
			$this->userids = [];
76
			$this->usernames = [];
77
			if ( !is_array( $this->params['user'] ) ) {
78
				$this->params['user'] = [ $this->params['user'] ];
79
			}
80
			if ( !count( $this->params['user'] ) ) {
81
				$this->dieUsage( 'User parameter may not be empty.', 'param_user' );
82
			}
83
			foreach ( $this->params['user'] as $u ) {
0 ignored issues
show
The expression $this->params['user'] of type null|array is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
84
				if ( is_null( $u ) || $u === '' ) {
85
					$this->dieUsage( 'User parameter may not be empty', 'param_user' );
86
				}
87
88
				if ( User::isIP( $u ) ) {
89
					$anyIPs = true;
90
					$this->usernames[] = $u;
91
				} else {
92
					$name = User::getCanonicalName( $u, 'valid' );
93
					if ( $name === false ) {
94
						$this->dieUsage( "User name {$u} is not valid", 'param_user' );
95
					}
96
					$this->usernames[] = $name;
97
				}
98
			}
99
			$this->prefixMode = false;
100
			$this->multiUserMode = ( count( $this->params['user'] ) > 1 );
101
102
			if ( !$anyIPs ) {
103
				$dbr = $this->getDB();
104
				$res = $dbr->select( 'user', 'user_id', [ 'user_name' => $this->usernames ], __METHOD__ );
105
				foreach ( $res as $row ) {
106
					$this->userids[] = $row->user_id;
107
				}
108
				$this->idMode = count( $this->userids ) === count( $this->usernames );
109
			}
110
		}
111
112
		$this->prepareQuery();
113
114
		$hookData = [];
115
		// Do the actual query.
116
		$res = $this->select( __METHOD__, [], $hookData );
117
118
		if ( $this->fld_sizediff ) {
119
			$revIds = [];
120
			foreach ( $res as $row ) {
121
				if ( $row->rev_parent_id ) {
122
					$revIds[] = $row->rev_parent_id;
123
				}
124
			}
125
			$this->parentLens = Revision::getParentLengths( $dbSecondary, $revIds );
126
			$res->rewind(); // reset
127
		}
128
129
		// Initialise some variables
130
		$count = 0;
131
		$limit = $this->params['limit'];
132
133
		// Fetch each row
134
		foreach ( $res as $row ) {
135
			if ( ++$count > $limit ) {
136
				// We've reached the one extra which shows that there are
137
				// additional pages to be had. Stop here...
138
				$this->setContinueEnumParameter( 'continue', $this->continueStr( $row ) );
139
				break;
140
			}
141
142
			$vals = $this->extractRowInfo( $row );
143
			$fit = $this->processRow( $row, $vals, $hookData ) &&
144
				$this->getResult()->addValue( [ 'query', $this->getModuleName() ], null, $vals );
145
			if ( !$fit ) {
146
				$this->setContinueEnumParameter( 'continue', $this->continueStr( $row ) );
147
				break;
148
			}
149
		}
150
151
		$this->getResult()->addIndexedTagName(
152
			[ 'query', $this->getModuleName() ],
153
			'item'
154
		);
155
	}
156
157
	/**
158
	 * Prepares the query and returns the limit of rows requested
159
	 */
160
	private function prepareQuery() {
161
		// We're after the revision table, and the corresponding page
162
		// row for anything we retrieve. We may also need the
163
		// recentchanges row and/or tag summary row.
164
		$user = $this->getUser();
165
		$tables = [ 'page', 'revision' ]; // Order may change
166
		$this->addWhere( 'page_id=rev_page' );
167
168
		// Handle continue parameter
169
		if ( !is_null( $this->params['continue'] ) ) {
170
			$continue = explode( '|', $this->params['continue'] );
171
			$db = $this->getDB();
172
			if ( $this->multiUserMode ) {
173
				$this->dieContinueUsageIf( count( $continue ) != 4 );
174
				$modeFlag = array_shift( $continue );
175
				$this->dieContinueUsageIf( !in_array( $modeFlag, [ 'id', 'name' ] ) );
176
				if ( $this->idMode && $modeFlag === 'name' ) {
177
					// The users were created since this query started, but we
178
					// can't go back and change modes now. So just keep on with
179
					// name mode.
180
					$this->idMode = false;
181
				}
182
				$this->dieContinueUsageIf( ( $modeFlag === 'id' ) !== $this->idMode );
183
				$userField = $this->idMode ? 'rev_user' : 'rev_user_text';
184
				$encUser = $db->addQuotes( array_shift( $continue ) );
185
			} else {
186
				$this->dieContinueUsageIf( count( $continue ) != 2 );
187
			}
188
			$encTS = $db->addQuotes( $db->timestamp( $continue[0] ) );
189
			$encId = (int)$continue[1];
190
			$this->dieContinueUsageIf( $encId != $continue[1] );
191
			$op = ( $this->params['dir'] == 'older' ? '<' : '>' );
192
			if ( $this->multiUserMode ) {
193
				$this->addWhere(
194
					"$userField $op $encUser OR " .
195
					"($userField = $encUser AND " .
196
					"(rev_timestamp $op $encTS OR " .
197
					"(rev_timestamp = $encTS AND " .
198
					"rev_id $op= $encId)))"
199
				);
200
			} else {
201
				$this->addWhere(
202
					"rev_timestamp $op $encTS OR " .
203
					"(rev_timestamp = $encTS AND " .
204
					"rev_id $op= $encId)"
205
				);
206
			}
207
		}
208
209
		// Don't include any revisions where we're not supposed to be able to
210
		// see the username.
211
		if ( !$user->isAllowed( 'deletedhistory' ) ) {
212
			$bitmask = Revision::DELETED_USER;
213
		} elseif ( !$user->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
214
			$bitmask = Revision::DELETED_USER | Revision::DELETED_RESTRICTED;
215
		} else {
216
			$bitmask = 0;
217
		}
218
		if ( $bitmask ) {
219
			$this->addWhere( $this->getDB()->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask" );
220
		}
221
222
		// We only want pages by the specified users.
223
		if ( $this->prefixMode ) {
224
			$this->addWhere( 'rev_user_text' .
225
				$this->getDB()->buildLike( $this->userprefix, $this->getDB()->anyString() ) );
226
		} elseif ( $this->idMode ) {
227
			$this->addWhereFld( 'rev_user', $this->userids );
228
		} else {
229
			$this->addWhereFld( 'rev_user_text', $this->usernames );
230
		}
231
		// ... and in the specified timeframe.
232
		// Ensure the same sort order for rev_user/rev_user_text and rev_timestamp
233
		// so our query is indexed
234
		if ( $this->multiUserMode ) {
235
			$this->addWhereRange( $this->idMode ? 'rev_user' : 'rev_user_text',
236
				$this->params['dir'], null, null );
237
		}
238
		$this->addTimestampWhereRange( 'rev_timestamp',
239
			$this->params['dir'], $this->params['start'], $this->params['end'] );
240
		// Include in ORDER BY for uniqueness
241
		$this->addWhereRange( 'rev_id', $this->params['dir'], null, null );
242
243
		$this->addWhereFld( 'page_namespace', $this->params['namespace'] );
244
245
		$show = $this->params['show'];
246
		if ( $this->params['toponly'] ) { // deprecated/old param
247
			$show[] = 'top';
248
		}
249
		if ( !is_null( $show ) ) {
250
			$show = array_flip( $show );
251
252 View Code Duplication
			if ( ( isset( $show['minor'] ) && isset( $show['!minor'] ) )
253
				|| ( isset( $show['patrolled'] ) && isset( $show['!patrolled'] ) )
254
				|| ( isset( $show['top'] ) && isset( $show['!top'] ) )
255
				|| ( isset( $show['new'] ) && isset( $show['!new'] ) )
256
			) {
257
				$this->dieUsageMsg( 'show' );
258
			}
259
260
			$this->addWhereIf( 'rev_minor_edit = 0', isset( $show['!minor'] ) );
261
			$this->addWhereIf( 'rev_minor_edit != 0', isset( $show['minor'] ) );
262
			$this->addWhereIf( 'rc_patrolled = 0', isset( $show['!patrolled'] ) );
263
			$this->addWhereIf( 'rc_patrolled != 0', isset( $show['patrolled'] ) );
264
			$this->addWhereIf( 'rev_id != page_latest', isset( $show['!top'] ) );
265
			$this->addWhereIf( 'rev_id = page_latest', isset( $show['top'] ) );
266
			$this->addWhereIf( 'rev_parent_id != 0', isset( $show['!new'] ) );
267
			$this->addWhereIf( 'rev_parent_id = 0', isset( $show['new'] ) );
268
		}
269
		$this->addOption( 'LIMIT', $this->params['limit'] + 1 );
270
271
		// Mandatory fields: timestamp allows request continuation
272
		// ns+title checks if the user has access rights for this page
273
		// user_text is necessary if multiple users were specified
274
		$this->addFields( [
275
			'rev_id',
276
			'rev_timestamp',
277
			'page_namespace',
278
			'page_title',
279
			'rev_user',
280
			'rev_user_text',
281
			'rev_deleted'
282
		] );
283
284
		if ( isset( $show['patrolled'] ) || isset( $show['!patrolled'] ) ||
285
			$this->fld_patrolled
286
		) {
287
			if ( !$user->useRCPatrol() && !$user->useNPPatrol() ) {
288
				$this->dieUsage(
289
					'You need the patrol right to request the patrolled flag',
290
					'permissiondenied'
291
				);
292
			}
293
294
			// Use a redundant join condition on both
295
			// timestamp and ID so we can use the timestamp
296
			// index
297
			$index['recentchanges'] = 'rc_user_text';
298
			if ( isset( $show['patrolled'] ) || isset( $show['!patrolled'] ) ) {
299
				// Put the tables in the right order for
300
				// STRAIGHT_JOIN
301
				$tables = [ 'revision', 'recentchanges', 'page' ];
302
				$this->addOption( 'STRAIGHT_JOIN' );
303
				$this->addWhere( 'rc_user_text=rev_user_text' );
304
				$this->addWhere( 'rc_timestamp=rev_timestamp' );
305
				$this->addWhere( 'rc_this_oldid=rev_id' );
306
			} else {
307
				$tables[] = 'recentchanges';
308
				$this->addJoinConds( [ 'recentchanges' => [
309
					'LEFT JOIN', [
310
						'rc_user_text=rev_user_text',
311
						'rc_timestamp=rev_timestamp',
312
						'rc_this_oldid=rev_id' ] ] ] );
313
			}
314
		}
315
316
		$this->addTables( $tables );
317
		$this->addFieldsIf( 'rev_page', $this->fld_ids );
318
		$this->addFieldsIf( 'page_latest', $this->fld_flags );
319
		// $this->addFieldsIf( 'rev_text_id', $this->fld_ids ); // Should this field be exposed?
320
		$this->addFieldsIf( 'rev_comment', $this->fld_comment || $this->fld_parsedcomment );
321
		$this->addFieldsIf( 'rev_len', $this->fld_size || $this->fld_sizediff );
322
		$this->addFieldsIf( 'rev_minor_edit', $this->fld_flags );
323
		$this->addFieldsIf( 'rev_parent_id', $this->fld_flags || $this->fld_sizediff || $this->fld_ids );
324
		$this->addFieldsIf( 'rc_patrolled', $this->fld_patrolled );
325
326 View Code Duplication
		if ( $this->fld_tags ) {
327
			$this->addTables( 'tag_summary' );
328
			$this->addJoinConds(
329
				[ 'tag_summary' => [ 'LEFT JOIN', [ 'rev_id=ts_rev_id' ] ] ]
330
			);
331
			$this->addFields( 'ts_tags' );
332
		}
333
334
		if ( isset( $this->params['tag'] ) ) {
335
			$this->addTables( 'change_tag' );
336
			$this->addJoinConds(
337
				[ 'change_tag' => [ 'INNER JOIN', [ 'rev_id=ct_rev_id' ] ] ]
338
			);
339
			$this->addWhereFld( 'ct_tag', $this->params['tag'] );
340
		}
341
342
		if ( isset( $index ) ) {
343
			$this->addOption( 'USE INDEX', $index );
344
		}
345
	}
346
347
	/**
348
	 * Extract fields from the database row and append them to a result array
349
	 *
350
	 * @param stdClass $row
351
	 * @return array
352
	 */
353
	private function extractRowInfo( $row ) {
354
		$vals = [];
355
		$anyHidden = false;
356
357
		if ( $row->rev_deleted & Revision::DELETED_TEXT ) {
358
			$vals['texthidden'] = true;
359
			$anyHidden = true;
360
		}
361
362
		// Any rows where we can't view the user were filtered out in the query.
363
		$vals['userid'] = (int)$row->rev_user;
364
		$vals['user'] = $row->rev_user_text;
365
		if ( $row->rev_deleted & Revision::DELETED_USER ) {
366
			$vals['userhidden'] = true;
367
			$anyHidden = true;
368
		}
369
		if ( $this->fld_ids ) {
370
			$vals['pageid'] = intval( $row->rev_page );
371
			$vals['revid'] = intval( $row->rev_id );
372
			// $vals['textid'] = intval( $row->rev_text_id ); // todo: Should this field be exposed?
373
374
			if ( !is_null( $row->rev_parent_id ) ) {
375
				$vals['parentid'] = intval( $row->rev_parent_id );
376
			}
377
		}
378
379
		$title = Title::makeTitle( $row->page_namespace, $row->page_title );
380
381
		if ( $this->fld_title ) {
382
			ApiQueryBase::addTitleInfo( $vals, $title );
383
		}
384
385
		if ( $this->fld_timestamp ) {
386
			$vals['timestamp'] = wfTimestamp( TS_ISO_8601, $row->rev_timestamp );
387
		}
388
389
		if ( $this->fld_flags ) {
390
			$vals['new'] = $row->rev_parent_id == 0 && !is_null( $row->rev_parent_id );
391
			$vals['minor'] = (bool)$row->rev_minor_edit;
392
			$vals['top'] = $row->page_latest == $row->rev_id;
393
		}
394
395 View Code Duplication
		if ( ( $this->fld_comment || $this->fld_parsedcomment ) && isset( $row->rev_comment ) ) {
396
			if ( $row->rev_deleted & Revision::DELETED_COMMENT ) {
397
				$vals['commenthidden'] = true;
398
				$anyHidden = true;
399
			}
400
401
			$userCanView = Revision::userCanBitfield(
402
				$row->rev_deleted,
403
				Revision::DELETED_COMMENT, $this->getUser()
404
			);
405
406
			if ( $userCanView ) {
407
				if ( $this->fld_comment ) {
408
					$vals['comment'] = $row->rev_comment;
409
				}
410
411
				if ( $this->fld_parsedcomment ) {
412
					$vals['parsedcomment'] = Linker::formatComment( $row->rev_comment, $title );
413
				}
414
			}
415
		}
416
417
		if ( $this->fld_patrolled ) {
418
			$vals['patrolled'] = (bool)$row->rc_patrolled;
419
		}
420
421
		if ( $this->fld_size && !is_null( $row->rev_len ) ) {
422
			$vals['size'] = intval( $row->rev_len );
423
		}
424
425
		if ( $this->fld_sizediff
426
			&& !is_null( $row->rev_len )
427
			&& !is_null( $row->rev_parent_id )
428
		) {
429
			$parentLen = isset( $this->parentLens[$row->rev_parent_id] )
430
				? $this->parentLens[$row->rev_parent_id]
431
				: 0;
432
			$vals['sizediff'] = intval( $row->rev_len - $parentLen );
433
		}
434
435 View Code Duplication
		if ( $this->fld_tags ) {
436
			if ( $row->ts_tags ) {
437
				$tags = explode( ',', $row->ts_tags );
438
				ApiResult::setIndexedTagName( $tags, 'tag' );
439
				$vals['tags'] = $tags;
440
			} else {
441
				$vals['tags'] = [];
442
			}
443
		}
444
445
		if ( $anyHidden && $row->rev_deleted & Revision::DELETED_RESTRICTED ) {
446
			$vals['suppressed'] = true;
447
		}
448
449
		return $vals;
450
	}
451
452
	private function continueStr( $row ) {
453
		if ( $this->multiUserMode ) {
454
			if ( $this->idMode ) {
455
				return "id|$row->rev_user|$row->rev_timestamp|$row->rev_id";
456
			} else {
457
				return "name|$row->rev_user_text|$row->rev_timestamp|$row->rev_id";
458
			}
459
		} else {
460
			return "$row->rev_timestamp|$row->rev_id";
461
		}
462
	}
463
464
	public function getCacheMode( $params ) {
465
		// This module provides access to deleted revisions and patrol flags if
466
		// the requester is logged in
467
		return 'anon-public-user-private';
468
	}
469
470
	public function getAllowedParams() {
471
		return [
472
			'limit' => [
473
				ApiBase::PARAM_DFLT => 10,
474
				ApiBase::PARAM_TYPE => 'limit',
475
				ApiBase::PARAM_MIN => 1,
476
				ApiBase::PARAM_MAX => ApiBase::LIMIT_BIG1,
477
				ApiBase::PARAM_MAX2 => ApiBase::LIMIT_BIG2
478
			],
479
			'start' => [
480
				ApiBase::PARAM_TYPE => 'timestamp'
481
			],
482
			'end' => [
483
				ApiBase::PARAM_TYPE => 'timestamp'
484
			],
485
			'continue' => [
486
				ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
487
			],
488
			'user' => [
489
				ApiBase::PARAM_TYPE => 'user',
490
				ApiBase::PARAM_ISMULTI => true
491
			],
492
			'userprefix' => null,
493
			'dir' => [
494
				ApiBase::PARAM_DFLT => 'older',
495
				ApiBase::PARAM_TYPE => [
496
					'newer',
497
					'older'
498
				],
499
				ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
500
			],
501
			'namespace' => [
502
				ApiBase::PARAM_ISMULTI => true,
503
				ApiBase::PARAM_TYPE => 'namespace'
504
			],
505
			'prop' => [
506
				ApiBase::PARAM_ISMULTI => true,
507
				ApiBase::PARAM_DFLT => 'ids|title|timestamp|comment|size|flags',
508
				ApiBase::PARAM_TYPE => [
509
					'ids',
510
					'title',
511
					'timestamp',
512
					'comment',
513
					'parsedcomment',
514
					'size',
515
					'sizediff',
516
					'flags',
517
					'patrolled',
518
					'tags'
519
				],
520
				ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
521
			],
522
			'show' => [
523
				ApiBase::PARAM_ISMULTI => true,
524
				ApiBase::PARAM_TYPE => [
525
					'minor',
526
					'!minor',
527
					'patrolled',
528
					'!patrolled',
529
					'top',
530
					'!top',
531
					'new',
532
					'!new',
533
				],
534
				ApiBase::PARAM_HELP_MSG => [
535
					'apihelp-query+usercontribs-param-show',
536
					$this->getConfig()->get( 'RCMaxAge' )
537
				],
538
			],
539
			'tag' => null,
540
			'toponly' => [
541
				ApiBase::PARAM_DFLT => false,
542
				ApiBase::PARAM_DEPRECATED => true,
543
			],
544
		];
545
	}
546
547
	protected function getExamplesMessages() {
548
		return [
549
			'action=query&list=usercontribs&ucuser=Example'
550
				=> 'apihelp-query+usercontribs-example-user',
551
			'action=query&list=usercontribs&ucuserprefix=192.0.2.'
552
				=> 'apihelp-query+usercontribs-example-ipprefix',
553
		];
554
	}
555
556
	public function getHelpUrls() {
557
		return 'https://www.mediawiki.org/wiki/API:Usercontribs';
558
	}
559
}
560