MediaWiki  master
PageHistoryCountHandler.php
Go to the documentation of this file.
1 <?php
2 
3 namespace MediaWiki\Rest\Handler;
4 
6 use ChangeTags;
18 use WANObjectCache;
24 
30  private const COUNT_LIMITS = [
31  'anonymous' => 10000,
32  'bot' => 10000,
33  'editors' => 25000,
34  'edits' => 30000,
35  'minor' => 1000,
36  'reverted' => 30000
37  ];
38 
39  private const DEPRECATED_COUNT_TYPES = [
40  'anonedits' => 'anonymous',
41  'botedits' => 'bot',
42  'revertededits' => 'reverted'
43  ];
44 
45  private const MAX_AGE_200 = 60;
46 
48  private $revisionStore;
49 
51  private $changeTagDefStore;
52 
54  private $groupPermissionsLookup;
55 
57  private $loadBalancer;
58 
60  private $pageLookup;
61 
63  private $cache;
64 
66  private $actorMigration;
67 
69  private $revision = false;
70 
72  private $lastModifiedTimes;
73 
75  private $page = false;
76 
86  public function __construct(
87  RevisionStore $revisionStore,
88  NameTableStoreFactory $nameTableStoreFactory,
89  GroupPermissionsLookup $groupPermissionsLookup,
90  ILoadBalancer $loadBalancer,
91  WANObjectCache $cache,
92  PageLookup $pageLookup,
93  ActorMigration $actorMigration
94  ) {
95  $this->revisionStore = $revisionStore;
96  $this->changeTagDefStore = $nameTableStoreFactory->getChangeTagDef();
97  $this->groupPermissionsLookup = $groupPermissionsLookup;
98  $this->loadBalancer = $loadBalancer;
99  $this->cache = $cache;
100  $this->pageLookup = $pageLookup;
101  $this->actorMigration = $actorMigration;
102  }
103 
104  private function normalizeType( $type ) {
105  return self::DEPRECATED_COUNT_TYPES[$type] ?? $type;
106  }
107 
114  private function validateParameterCombination( $type ) {
115  $params = $this->getValidatedParams();
116  if ( !$params ) {
117  return;
118  }
119 
120  if ( $params['from'] || $params['to'] ) {
121  if ( $type === 'edits' || $type === 'editors' ) {
122  if ( !$params['from'] || !$params['to'] ) {
123  throw new LocalizedHttpException(
124  new MessageValue( 'rest-pagehistorycount-parameters-invalid' ),
125  400
126  );
127  }
128  } else {
129  throw new LocalizedHttpException(
130  new MessageValue( 'rest-pagehistorycount-parameters-invalid' ),
131  400
132  );
133  }
134  }
135  }
136 
143  public function run( $title, $type ) {
144  $normalizedType = $this->normalizeType( $type );
145  $this->validateParameterCombination( $normalizedType );
146  $page = $this->getPage();
147  if ( !$page ) {
148  throw new LocalizedHttpException(
149  new MessageValue( 'rest-nonexistent-title',
150  [ new ScalarParam( ParamType::PLAINTEXT, $title ) ]
151  ),
152  404
153  );
154  }
155 
156  if ( !$this->getAuthority()->authorizeRead( 'read', $page ) ) {
157  throw new LocalizedHttpException(
158  new MessageValue( 'rest-permission-denied-title',
159  [ new ScalarParam( ParamType::PLAINTEXT, $title ) ]
160  ),
161  403
162  );
163  }
164 
165  $count = $this->getCount( $normalizedType );
166  $countLimit = self::COUNT_LIMITS[$normalizedType];
167  $response = $this->getResponseFactory()->createJson( [
168  'count' => $count > $countLimit ? $countLimit : $count,
169  'limit' => $count > $countLimit
170  ] );
171  $response->setHeader( 'Cache-Control', 'max-age=' . self::MAX_AGE_200 );
172 
173  // Inform clients who use a deprecated "type" value, so they can adjust
174  if ( isset( self::DEPRECATED_COUNT_TYPES[$type] ) ) {
175  $docs = '<https://www.mediawiki.org/wiki/API:REST/History_API' .
176  '#Get_page_history_counts>; rel="deprecation"';
177  $response->setHeader( 'Deprecation', 'version="v1"' );
178  $response->setHeader( 'Link', $docs );
179  }
180 
181  return $response;
182  }
183 
189  private function getCount( $type ) {
190  $pageId = $this->getPage()->getId();
191  switch ( $type ) {
192  case 'anonymous':
193  return $this->getCachedCount( $type,
194  function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
195  return $this->getAnonCount( $pageId, $fromRev );
196  }
197  );
198 
199  case 'bot':
200  return $this->getCachedCount( $type,
201  function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
202  return $this->getBotCount( $pageId, $fromRev );
203  }
204  );
205 
206  case 'editors':
207  $from = $this->getValidatedParams()['from'] ?? null;
208  $to = $this->getValidatedParams()['to'] ?? null;
209  if ( $from || $to ) {
210  return $this->getEditorsCount(
211  $pageId,
212  $from ? $this->getRevisionOrThrow( $from ) : null,
213  $to ? $this->getRevisionOrThrow( $to ) : null
214  );
215  } else {
216  return $this->getCachedCount( $type,
217  function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
218  return $this->getEditorsCount( $pageId, $fromRev );
219  } );
220  }
221 
222  case 'edits':
223  $from = $this->getValidatedParams()['from'] ?? null;
224  $to = $this->getValidatedParams()['to'] ?? null;
225  if ( $from || $to ) {
226  return $this->getEditsCount(
227  $pageId,
228  $from ? $this->getRevisionOrThrow( $from ) : null,
229  $to ? $this->getRevisionOrThrow( $to ) : null
230  );
231  } else {
232  return $this->getCachedCount( $type,
233  function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
234  return $this->getEditsCount( $pageId, $fromRev );
235  }
236  );
237  }
238 
239  case 'reverted':
240  return $this->getCachedCount( $type,
241  function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
242  return $this->getRevertedCount( $pageId, $fromRev );
243  }
244  );
245 
246  case 'minor':
247  // The query for minor counts is inefficient for the database for pages with many revisions.
248  // If the specified title contains more revisions than allowed, we will return an error.
249  $editsCount = $this->getCachedCount( 'edits',
250  function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
251  return $this->getEditsCount( $pageId, $fromRev );
252  }
253  );
254  if ( $editsCount > self::COUNT_LIMITS[$type] * 2 ) {
255  throw new LocalizedHttpException(
256  new MessageValue( 'rest-pagehistorycount-too-many-revisions' ),
257  500
258  );
259  }
260  return $this->getCachedCount( $type,
261  function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
262  return $this->getMinorCount( $pageId, $fromRev );
263  }
264  );
265 
266  default:
267  throw new LocalizedHttpException(
268  new MessageValue( 'rest-pagehistorycount-type-unrecognized',
269  [ new ScalarParam( ParamType::PLAINTEXT, $type ) ]
270  ),
271  500
272  );
273  }
274  }
275 
279  private function getCurrentRevision(): ?RevisionRecord {
280  if ( $this->revision === false ) {
281  $page = $this->getPage();
282  if ( $page ) {
283  $this->revision = $this->revisionStore->getKnownCurrentRevision( $page ) ?: null;
284  } else {
285  $this->revision = null;
286  }
287  }
288  return $this->revision;
289  }
290 
294  private function getPage(): ?ExistingPageRecord {
295  if ( $this->page === false ) {
296  $this->page = $this->pageLookup->getExistingPageByText(
297  $this->getValidatedParams()['title']
298  );
299  }
300  return $this->page;
301  }
302 
309  protected function getLastModified() {
310  $lastModifiedTimes = $this->getLastModifiedTimes();
311  if ( $lastModifiedTimes ) {
312  return max( array_values( $lastModifiedTimes ) );
313  }
314  return null;
315  }
316 
323  protected function getLastModifiedTimes() {
324  $currentRev = $this->getCurrentRevision();
325  if ( !$currentRev ) {
326  return null;
327  }
328  if ( $this->lastModifiedTimes === null ) {
329  $currentRevTime = (int)wfTimestampOrNull( TS_UNIX, $currentRev->getTimestamp() );
330  $loggingTableTime = $this->loggingTableTime( $currentRev->getPageId() );
331  $this->lastModifiedTimes = [
332  'currentRevTS' => $currentRevTime,
333  'dependencyModTS' => $loggingTableTime
334  ];
335  }
336  return $this->lastModifiedTimes;
337  }
338 
344  private function loggingTableTime( $pageId ) {
345  $res = $this->loadBalancer->getConnectionRef( DB_REPLICA )->selectField(
346  'logging',
347  'MAX(log_timestamp)',
348  [ 'log_page' => $pageId ],
349  __METHOD__
350  );
351  return $res ? (int)wfTimestamp( TS_UNIX, $res ) : null;
352  }
353 
364  protected function getEtag() {
365  return null;
366  }
367 
373  private function getCachedCount( $type,
374  callable $fetchCount
375  ) {
376  $pageId = $this->getPage()->getId();
377  return $this->cache->getWithSetCallback(
378  $this->cache->makeKey( 'rest', 'pagehistorycount', $pageId, $type ),
379  WANObjectCache::TTL_WEEK,
380  function ( $oldValue ) use ( $fetchCount ) {
381  $currentRev = $this->getCurrentRevision();
382  if ( $oldValue ) {
383  // Last modified timestamp was NOT a dependency change (e.g. revdel)
384  $doIncrementalUpdate = (
385  // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
386  $this->getLastModified() != $this->getLastModifiedTimes()['dependencyModTS']
387  );
388  if ( $doIncrementalUpdate ) {
389  $rev = $this->revisionStore->getRevisionById( $oldValue['revision'] );
390  if ( $rev ) {
391  $additionalCount = $fetchCount( $rev );
392  return [
393  'revision' => $currentRev->getId(),
394  'count' => $oldValue['count'] + $additionalCount,
395  // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
396  'dependencyModTS' => $this->getLastModifiedTimes()['dependencyModTS']
397  ];
398  }
399  }
400  }
401  // Nothing was previously stored, or incremental update was done for too long,
402  // recalculate from scratch.
403  return [
404  'revision' => $currentRev->getId(),
405  'count' => $fetchCount(),
406  // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
407  'dependencyModTS' => $this->getLastModifiedTimes()['dependencyModTS']
408  ];
409  },
410  [
411  'touchedCallback' => function (){
412  return $this->getLastModified();
413  },
414  'version' => 2,
415  'lockTSE' => WANObjectCache::TTL_MINUTE * 5
416  ]
417  )['count'];
418  }
419 
425  protected function getAnonCount( $pageId, RevisionRecord $fromRev = null ) {
426  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
427 
428  $revQuery = $this->actorMigration->getJoin( 'rev_user' );
429 
430  $cond = [
431  'rev_page' => $pageId,
432  'actor_user IS NULL',
433  $dbr->bitAnd( 'rev_deleted',
434  RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) . " = 0"
435  ];
436 
437  if ( $fromRev ) {
438  $oldTs = $dbr->addQuotes( $dbr->timestamp( $fromRev->getTimestamp() ) );
439  $cond[] = "(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
440  "OR rev_timestamp > {$oldTs}";
441  }
442 
443  $edits = $dbr->selectRowCount(
444  [
445  'revision',
446  ] + $revQuery['tables'],
447  '1',
448  $cond,
449  __METHOD__,
450  [ 'LIMIT' => self::COUNT_LIMITS['anonymous'] + 1 ], // extra to detect truncation
451  $revQuery['joins']
452  );
453  return $edits;
454  }
455 
461  protected function getBotCount( $pageId, RevisionRecord $fromRev = null ) {
462  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
463 
464  $revQuery = $this->actorMigration->getJoin( 'rev_user' );
465 
466  // This redundant join condition tells MySQL that rev_page and revactor_page are the
467  // same, so it can propagate the condition
468  if ( isset( $revQuery['tables']['temp_rev_user'] ) /* SCHEMA_COMPAT_READ_TEMP */ ) {
469  $revQuery['joins']['temp_rev_user'][1] =
470  "temp_rev_user.revactor_rev = rev_id AND revactor_page = rev_page";
471  }
472 
473  $cond = [
474  'rev_page=' . intval( $pageId ),
475  $dbr->bitAnd( 'rev_deleted',
476  RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) . " = 0",
477  'EXISTS(' .
478  $dbr->selectSQLText(
479  'user_groups',
480  '1',
481  [
482  $revQuery['fields']['rev_user'] . ' = ug_user',
483  'ug_group' => $this->groupPermissionsLookup->getGroupsWithPermission( 'bot' ),
484  'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() )
485  ],
486  __METHOD__
487  ) .
488  ')'
489  ];
490  if ( $fromRev ) {
491  $oldTs = $dbr->addQuotes( $dbr->timestamp( $fromRev->getTimestamp() ) );
492  $cond[] = "(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
493  "OR rev_timestamp > {$oldTs}";
494  }
495 
496  $edits = $dbr->selectRowCount(
497  [
498  'revision',
499  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
500  ] + $revQuery['tables'],
501  '1',
502  $cond,
503  __METHOD__,
504  [ 'LIMIT' => self::COUNT_LIMITS['bot'] + 1 ], // extra to detect truncation
505  $revQuery['joins']
506  );
507  return $edits;
508  }
509 
516  protected function getEditorsCount( $pageId,
517  RevisionRecord $fromRev = null,
518  RevisionRecord $toRev = null
519  ) {
520  list( $fromRev, $toRev ) = $this->orderRevisions( $fromRev, $toRev );
521  return $this->revisionStore->countAuthorsBetween( $pageId, $fromRev,
522  $toRev, $this->getAuthority(), self::COUNT_LIMITS['editors'] );
523  }
524 
530  protected function getRevertedCount( $pageId, RevisionRecord $fromRev = null ) {
531  $tagIds = [];
532 
533  foreach ( ChangeTags::REVERT_TAGS as $tagName ) {
534  try {
535  $tagIds[] = $this->changeTagDefStore->getId( $tagName );
536  } catch ( NameTableAccessException $e ) {
537  // If no revisions are tagged with a name, no tag id will be present
538  }
539  }
540  if ( !$tagIds ) {
541  return 0;
542  }
543 
544  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
545 
546  $cond = [
547  'rev_page' => $pageId,
548  $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
549  ];
550  if ( $fromRev ) {
551  $oldTs = $dbr->addQuotes( $dbr->timestamp( $fromRev->getTimestamp() ) );
552  $cond[] = "(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
553  "OR rev_timestamp > {$oldTs}";
554  }
555  $edits = $dbr->selectRowCount(
556  [
557  'revision',
558  'change_tag'
559  ],
560  '1',
561  [ 'rev_page' => $pageId ],
562  __METHOD__,
563  [
564  'LIMIT' => self::COUNT_LIMITS['reverted'] + 1, // extra to detect truncation
565  'GROUP BY' => 'rev_id'
566  ],
567  [
568  'change_tag' => [
569  'JOIN',
570  [
571  'ct_rev_id = rev_id',
572  'ct_tag_id' => $tagIds,
573  ]
574  ],
575  ]
576  );
577  return $edits;
578  }
579 
585  protected function getMinorCount( $pageId, RevisionRecord $fromRev = null ) {
586  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
587  $cond = [
588  'rev_page' => $pageId,
589  'rev_minor_edit != 0',
590  $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
591  ];
592  if ( $fromRev ) {
593  $oldTs = $dbr->addQuotes( $dbr->timestamp( $fromRev->getTimestamp() ) );
594  $cond[] = "(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
595  "OR rev_timestamp > {$oldTs}";
596  }
597  $edits = $dbr->selectRowCount( 'revision', '1',
598  $cond,
599  __METHOD__,
600  [ 'LIMIT' => self::COUNT_LIMITS['minor'] + 1 ] // extra to detect truncation
601  );
602 
603  return $edits;
604  }
605 
612  protected function getEditsCount(
613  $pageId,
614  RevisionRecord $fromRev = null,
615  RevisionRecord $toRev = null
616  ) {
617  list( $fromRev, $toRev ) = $this->orderRevisions( $fromRev, $toRev );
618  return $this->revisionStore->countRevisionsBetween(
619  $pageId,
620  $fromRev,
621  $toRev,
622  self::COUNT_LIMITS['edits'] // Will be increased by 1 to detect truncation
623  );
624  }
625 
631  private function getRevisionOrThrow( $revId ) {
632  $rev = $this->revisionStore->getRevisionById( $revId );
633  if ( !$rev ) {
634  throw new LocalizedHttpException(
635  new MessageValue( 'rest-nonexistent-revision', [ $revId ] ),
636  404
637  );
638  }
639  return $rev;
640  }
641 
649  private function orderRevisions(
650  RevisionRecord $fromRev = null,
651  RevisionRecord $toRev = null
652  ) {
653  if ( $fromRev && $toRev && ( $fromRev->getTimestamp() > $toRev->getTimestamp() ||
654  ( $fromRev->getTimestamp() === $toRev->getTimestamp()
655  && $fromRev->getId() > $toRev->getId() ) )
656  ) {
657  return [ $toRev, $fromRev ];
658  }
659  return [ $fromRev, $toRev ];
660  }
661 
662  public function needsWriteAccess() {
663  return false;
664  }
665 
666  public function getParamSettings() {
667  return [
668  'title' => [
669  self::PARAM_SOURCE => 'path',
670  ParamValidator::PARAM_TYPE => 'string',
671  ParamValidator::PARAM_REQUIRED => true,
672  ],
673  'type' => [
674  self::PARAM_SOURCE => 'path',
675  ParamValidator::PARAM_TYPE => array_merge(
676  array_keys( self::COUNT_LIMITS ),
677  array_keys( self::DEPRECATED_COUNT_TYPES )
678  ),
679  ParamValidator::PARAM_REQUIRED => true,
680  ],
681  'from' => [
682  self::PARAM_SOURCE => 'query',
683  ParamValidator::PARAM_TYPE => 'integer',
684  ParamValidator::PARAM_REQUIRED => false
685  ],
686  'to' => [
687  self::PARAM_SOURCE => 'query',
688  ParamValidator::PARAM_TYPE => 'integer',
689  ParamValidator::PARAM_REQUIRED => false
690  ]
691  ];
692  }
693 }
getAuthority()
wfTimestampOrNull( $outputtype=TS_UNIX, $ts=null)
Return a formatted timestamp, or null if input is null.
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
if(!defined('MW_SETUP_CALLBACK'))
The persistent session ID (if any) loaded at startup.
Definition: WebStart.php:82
This is not intended to be a long-term part of MediaWiki; it will be deprecated and removed once acto...
const REVERT_TAGS
List of tags which denote a revert of some sort.
Definition: ChangeTags.php:97
Handler class for Core REST API endpoints that perform operations on revisions.
getAnonCount( $pageId, RevisionRecord $fromRev=null)
getMinorCount( $pageId, RevisionRecord $fromRev=null)
__construct(RevisionStore $revisionStore, NameTableStoreFactory $nameTableStoreFactory, GroupPermissionsLookup $groupPermissionsLookup, ILoadBalancer $loadBalancer, WANObjectCache $cache, PageLookup $pageLookup, ActorMigration $actorMigration)
getLastModifiedTimes()
Returns array with 2 timestamps:
getEtag()
Choosing to not implement etags in this handler.
getBotCount( $pageId, RevisionRecord $fromRev=null)
needsWriteAccess()
Indicates whether this route requires write access.
getRevertedCount( $pageId, RevisionRecord $fromRev=null)
getEditorsCount( $pageId, RevisionRecord $fromRev=null, RevisionRecord $toRev=null)
getParamSettings()
Fetch ParamValidator settings for parameters.
getEditsCount( $pageId, RevisionRecord $fromRev=null, RevisionRecord $toRev=null)
getValidatedParams()
Fetch the validated parameters.
Definition: Handler.php:336
getAuthority()
Get the current acting authority.
Definition: Handler.php:157
getResponseFactory()
Get the ResponseFactory which can be used to generate Response objects.
Definition: Handler.php:179
Page revision base class.
Service for looking up page revisions.
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.
Multi-datacenter aware caching interface.
Value object representing a message for i18n.
The constants used to specify parameter types.
Definition: ParamType.php:11
Value object representing a message parameter holding a single value.
Definition: ScalarParam.php:14
Service for formatting and validating API parameters.
Data record representing a page that currently exists as an editable page on a wiki.
Service for looking up information about wiki pages.
Definition: PageLookup.php:16
Create and track the database connections and transactions for a given database cluster.
$cache
Definition: mcc.php:33
Copyright (C) 2011-2020 Wikimedia Foundation and others.
const DB_REPLICA
Definition: defines.php:26
$revQuery