MediaWiki  master
PageHistoryCountHandler.php
Go to the documentation of this file.
1 <?php
2 
3 namespace MediaWiki\Rest\Handler;
4 
14 use RequestContext;
15 use User;
20 use Title;
22 
28  private const LIMIT = 1000;
29 
30  const ALLOWED_COUNT_TYPES = [ 'anonymous', 'bot', 'editors', 'edits', 'reverted', 'minor' ];
31 
32  // These types work identically to their similarly-named synonyms, but will be removed in the
33  // next major version of the API. Callers should use the corresponding non-deprecated type.
35  'anonedits', 'botedits', 'revertededits'
36  ];
37 
38  // The query for minor counts is inefficient for the database for pages with many revisions.
39  // If the specified title contains more revisions than allowed, we will return an error.
40  // This may be fixed with a database index, per T235572. If so, this check can be removed.
42 
43  const REVERTED_TAG_NAMES = [ 'mw-undo', 'mw-rollback' ];
44 
46  private $revisionStore;
47 
50 
53 
55  private $loadBalancer;
56 
58  private $user;
59 
66  public function __construct(
68  NameTableStoreFactory $nameTableStoreFactory,
71  ) {
72  $this->revisionStore = $revisionStore;
73  $this->changeTagDefStore = $nameTableStoreFactory->getChangeTagDef();
74  $this->permissionManager = $permissionManager;
75  $this->loadBalancer = $loadBalancer;
76 
77  // @todo Inject this, when there is a good way to do that
78  $this->user = RequestContext::getMain()->getUser();
79  }
80 
87  private function validateParameterCombination( $type ) {
88  $params = $this->getValidatedParams();
89  if ( !$params ) {
90  return;
91  }
92 
93  if ( $params['from'] || $params['to'] ) {
94  if ( $type === 'edits' || $type === 'editors' ) {
95  if ( !$params['from'] || !$params['to'] ) {
96  throw new LocalizedHttpException(
97  new MessageValue( 'rest-pagehistorycount-parameters-invalid' ),
98  400
99  );
100  }
101  } else {
102  throw new LocalizedHttpException(
103  new MessageValue( 'rest-pagehistorycount-parameters-invalid' ),
104  400
105  );
106  }
107  }
108  }
109 
116  public function run( $title, $type ) {
118  $titleObj = Title::newFromText( $title );
119  if ( !$titleObj || !$titleObj->getArticleID() ) {
120  throw new LocalizedHttpException(
121  new MessageValue( 'rest-nonexistent-title',
122  [ new ScalarParam( ParamType::PLAINTEXT, $title ) ]
123  ),
124  404
125  );
126  }
127 
128  if ( !$this->permissionManager->userCan( 'read', $this->user, $titleObj ) ) {
129  throw new LocalizedHttpException(
130  new MessageValue( 'rest-permission-denied-title',
131  [ new ScalarParam( ParamType::PLAINTEXT, $title ) ]
132  ),
133  403
134  );
135  }
136 
137  $count = $this->getCount( $titleObj->getArticleID(), $type );
138  $response = $this->getResponseFactory()->createJson( [
139  'count' => $count > self::LIMIT ? self::LIMIT : $count,
140  'limit' => $count > self::LIMIT
141  ] );
142 
143  // Inform clients who use a deprecated "type" value, so they can adjust
144  if ( in_array( $type, self::DEPRECATED_COUNT_TYPES ) ) {
145  $docs = '<https://www.mediawiki.org/wiki/API:REST/History_API' .
146  '#Get_page_history_counts>; rel="deprecation"';
147  $response->setHeader( 'Deprecation', 'version="v1"' );
148  $response->setHeader( 'Link', $docs );
149  }
150 
151  return $response;
152  }
153 
160  protected function getCount( $pageId, $type ) {
161  switch ( $type ) {
162  case 'anonedits':
163  case 'anonymous':
164  return $this->getAnonCount( $pageId );
165 
166  case 'botedits':
167  case 'bot':
168  return $this->getBotCount( $pageId );
169 
170  case 'editors':
171  return $this->getEditorsCount( $pageId );
172 
173  case 'edits':
174  return $this->getEditsCount( $pageId, self::LIMIT );
175 
176  case 'revertededits':
177  case 'reverted':
178  return $this->getRevertedCount( $pageId );
179 
180  case 'minor':
181  $editsCount = $this->getEditsCount( $pageId, self::MINOR_QUERY_EDIT_COUNT_LIMIT );
182  if ( $editsCount > self::MINOR_QUERY_EDIT_COUNT_LIMIT ) {
183  throw new LocalizedHttpException(
184  new MessageValue( 'rest-pagehistorycount-too-many-revisions' ),
185  500
186  );
187  }
188  return $this->getMinorCount( $pageId );
189  // Sanity check
190  default:
191  throw new LocalizedHttpException(
192  new MessageValue( 'rest-pagehistorycount-type-unrecognized',
193  [ new ScalarParam( ParamType::PLAINTEXT, $type ) ]
194  ),
195  500
196  );
197  }
198  }
199 
204  protected function getAnonCount( $pageId ) {
205  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
206 
207  $cond = [
208  'rev_page' => $pageId,
209  'actor_user IS NULL',
210  ];
211  $bitmask = $this->getBitmask();
212  if ( $bitmask ) {
213  $cond[] = $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask";
214  }
215 
216  $edits = $dbr->selectRowCount(
217  [
218  'revision_actor_temp',
219  'revision',
220  'actor'
221  ],
222  '1',
223  $cond,
224  __METHOD__,
225  [ 'LIMIT' => self::LIMIT + 1 ], // extra to detect truncation
226  [
227  'revision' => [
228  'JOIN',
229  'revactor_rev = rev_id AND revactor_page = rev_page'
230  ],
231  'actor' => [
232  'JOIN',
233  'revactor_actor = actor_id'
234  ]
235  ]
236  );
237  return $edits;
238  }
239 
244  protected function getBotCount( $pageId ) {
245  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
246 
247  $cond = [
248  'rev_page' => $pageId,
249  'EXISTS(' .
250  $dbr->selectSQLText(
251  'user_groups',
252  1,
253  [
254  'actor.actor_user = ug_user',
255  'ug_group' => $this->permissionManager->getGroupsWithPermission( 'bot' ),
256  'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() )
257  ],
258  __METHOD__
259  ) .
260  ')'
261  ];
262  $bitmask = $this->getBitmask();
263  if ( $bitmask ) {
264  $cond[] = $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask";
265  }
266 
267  $edits = $dbr->selectRowCount(
268  [
269  'revision_actor_temp',
270  'revision',
271  'actor',
272  ],
273  '1',
274  $cond,
275  __METHOD__,
276  [ 'LIMIT' => self::LIMIT + 1 ], // extra to detect truncation
277  [
278  'revision' => [
279  'JOIN',
280  'revactor_rev = rev_id AND revactor_page = rev_page'
281  ],
282  'actor' => [
283  'JOIN',
284  'revactor_actor = actor_id'
285  ],
286  ]
287  );
288  return $edits;
289  }
290 
296  protected function getEditorsCount( $pageId ) {
297  $from = $this->getValidatedParams()['from'] ?? null;
298  $to = $this->getValidatedParams()['to'] ?? null;
299  $fromRev = $from ? $this->getRevisionOrThrow( $from ) : null;
300  $toRev = $to ? $this->getRevisionOrThrow( $to ) : null;
301 
302  // Reorder from and to parameters if they are out of order.
303  if ( $fromRev && $toRev && ( $fromRev->getTimestamp() > $toRev->getTimestamp() ||
304  ( $fromRev->getTimestamp() === $toRev->getTimestamp() && $from > $to ) )
305  ) {
306  $tmp = $fromRev;
307  $fromRev = $toRev;
308  $toRev = $tmp;
309  }
310 
311  return $this->revisionStore->countAuthorsBetween( $pageId, $fromRev,
312  $toRev, $this->user, self::LIMIT );
313  }
314 
319  protected function getRevertedCount( $pageId ) {
320  $tagIds = [];
321 
322  foreach ( self::REVERTED_TAG_NAMES as $tagName ) {
323  try {
324  $tagIds[] = $this->changeTagDefStore->getId( $tagName );
325  } catch ( NameTableAccessException $e ) {
326  // If no revisions are tagged with a name, no tag id will be present
327  }
328  }
329  if ( !$tagIds ) {
330  return 0;
331  }
332 
333  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
334  $edits = $dbr->selectRowCount(
335  [
336  'revision',
337  'change_tag'
338  ],
339  '1',
340  [ 'rev_page' => $pageId ],
341  __METHOD__,
342  [
343  'LIMIT' => self::LIMIT + 1, // extra to detect truncation
344  'GROUP BY' => 'rev_id'
345  ],
346  [
347  'change_tag' => [
348  'JOIN',
349  [
350  'ct_rev_id = rev_id',
351  'ct_tag_id' => $tagIds,
352  ]
353  ],
354  ]
355  );
356  return $edits;
357  }
358 
363  protected function getMinorCount( $pageId ) {
364  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
365  $edits = $dbr->selectRowCount( 'revision', '1',
366  [
367  'rev_page' => $pageId,
368  'rev_minor_edit != 0'
369  ],
370  __METHOD__,
371  [ 'LIMIT' => self::LIMIT + 1 ] // extra to detect truncation
372  );
373 
374  return $edits;
375  }
376 
383  protected function getEditsCount( $pageId, $limit ) {
384  $from = $this->getValidatedParams()['from'] ?? null;
385  $to = $this->getValidatedParams()['to'] ?? null;
386  $fromRev = $from ? $this->getRevisionOrThrow( $from ) : null;
387  $toRev = $to ? $this->getRevisionOrThrow( $to ) : null;
388 
389  // Reorder from and to parameters if they are out of order.
390  if ( $fromRev && $toRev && ( $fromRev->getTimestamp() > $toRev->getTimestamp() ||
391  ( $fromRev->getTimestamp() === $toRev->getTimestamp() && $from > $to ) )
392  ) {
393  $tmp = $fromRev;
394  $fromRev = $toRev;
395  $toRev = $tmp;
396  }
397  return $this->revisionStore->countRevisionsBetween(
398  $pageId,
399  $fromRev,
400  $toRev,
401  $limit // Will be increased by 1 to detect truncation
402  );
403  }
404 
412  private function getBitmask() {
413  if ( !$this->permissionManager->userHasRight( $this->user, 'deletedhistory' ) ) {
414  $bitmask = RevisionRecord::DELETED_USER;
415  } elseif ( !$this->permissionManager
416  ->userHasAnyRight( $this->user, 'suppressrevision', 'viewsuppressed' )
417  ) {
418  $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED;
419  } else {
420  $bitmask = 0;
421  }
422  return $bitmask;
423  }
424 
430  private function getRevisionOrThrow( $revId ) {
431  $rev = $this->revisionStore->getRevisionById( $revId );
432  if ( !$rev ) {
433  throw new LocalizedHttpException(
434  new MessageValue( 'rest-nonexistent-revision', [ $revId ] ),
435  404
436  );
437  }
438  return $rev;
439  }
440 
441  public function needsWriteAccess() {
442  return false;
443  }
444 
445  public function getParamSettings() {
446  return [
447  'title' => [
448  self::PARAM_SOURCE => 'path',
449  ParamValidator::PARAM_TYPE => 'string',
450  ParamValidator::PARAM_REQUIRED => true,
451  ],
452  'type' => [
453  self::PARAM_SOURCE => 'path',
454  ParamValidator::PARAM_TYPE => array_merge(
455  self::ALLOWED_COUNT_TYPES,
456  self::DEPRECATED_COUNT_TYPES
457  ),
458  ParamValidator::PARAM_REQUIRED => true,
459  ],
460  'from' => [
461  self::PARAM_SOURCE => 'query',
462  ParamValidator::PARAM_TYPE => 'integer',
463  ParamValidator::PARAM_REQUIRED => false
464  ],
465  'to' => [
466  self::PARAM_SOURCE => 'query',
467  ParamValidator::PARAM_TYPE => 'integer',
468  ParamValidator::PARAM_REQUIRED => false
469  ]
470  ];
471  }
472 }
$response
Exception representing a failure to look up a row from a name table.
getChangeTagDef( $wiki=false)
Get a NameTableStore for the change_tag_def table.
Value object representing a message for i18n.
The User object encapsulates all of the user-specific settings (user_id, name, rights, email address, options, last login time).
Definition: User.php:51
getResponseFactory()
Get the ResponseFactory which can be used to generate Response objects.
Definition: Handler.php:92
static getMain()
Get the RequestContext object associated with the main request.
Service for looking up page revisions.
Handler class for Core REST API endpoints that perform operations on revisions.
validateParameterCombination( $type)
Validates that the provided parameter combination is supported.
const LIMIT
int The maximum number of revisions to count
Database cluster connection, tracking, load balancing, and transaction manager interface.
const DB_REPLICA
Definition: defines.php:25
__construct(RevisionStore $revisionStore, NameTableStoreFactory $nameTableStoreFactory, PermissionManager $permissionManager, ILoadBalancer $loadBalancer)
getValidatedParams()
Fetch the validated parameters.
Definition: Handler.php:174
getBitmask()
Helper function for rev_deleted/user rights query conditions.
Value object representing a message parameter holding a single value.
Definition: ScalarParam.php:10
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
static newFromText( $text, $defaultNamespace=NS_MAIN)
Create a new Title from text, such as what one would find in a link.
Definition: Title.php:317