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  $cond[] = $dbr->buildComparison( '>', [
439  'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
440  'rev_id' => $fromRev->getId(),
441  ] );
442  }
443 
444  $edits = $dbr->selectRowCount(
445  [
446  'revision',
447  ] + $revQuery['tables'],
448  '1',
449  $cond,
450  __METHOD__,
451  [ 'LIMIT' => self::COUNT_LIMITS['anonymous'] + 1 ], // extra to detect truncation
452  $revQuery['joins']
453  );
454  return $edits;
455  }
456 
462  protected function getBotCount( $pageId, RevisionRecord $fromRev = null ) {
463  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
464 
465  $revQuery = $this->actorMigration->getJoin( 'rev_user' );
466 
467  $cond = [
468  'rev_page=' . intval( $pageId ),
469  $dbr->bitAnd( 'rev_deleted',
470  RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) . " = 0",
471  'EXISTS(' .
472  $dbr->selectSQLText(
473  'user_groups',
474  '1',
475  [
476  $revQuery['fields']['rev_user'] . ' = ug_user',
477  'ug_group' => $this->groupPermissionsLookup->getGroupsWithPermission( 'bot' ),
478  'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() )
479  ],
480  __METHOD__
481  ) .
482  ')'
483  ];
484  if ( $fromRev ) {
485  $cond[] = $dbr->buildComparison( '>', [
486  'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
487  'rev_id' => $fromRev->getId(),
488  ] );
489  }
490 
491  $edits = $dbr->selectRowCount(
492  [
493  'revision',
494  ] + $revQuery['tables'],
495  '1',
496  $cond,
497  __METHOD__,
498  [ 'LIMIT' => self::COUNT_LIMITS['bot'] + 1 ], // extra to detect truncation
499  $revQuery['joins']
500  );
501  return $edits;
502  }
503 
510  protected function getEditorsCount( $pageId,
511  RevisionRecord $fromRev = null,
512  RevisionRecord $toRev = null
513  ) {
514  [ $fromRev, $toRev ] = $this->orderRevisions( $fromRev, $toRev );
515  return $this->revisionStore->countAuthorsBetween( $pageId, $fromRev,
516  $toRev, $this->getAuthority(), self::COUNT_LIMITS['editors'] );
517  }
518 
524  protected function getRevertedCount( $pageId, RevisionRecord $fromRev = null ) {
525  $tagIds = [];
526 
527  foreach ( ChangeTags::REVERT_TAGS as $tagName ) {
528  try {
529  $tagIds[] = $this->changeTagDefStore->getId( $tagName );
530  } catch ( NameTableAccessException $e ) {
531  // If no revisions are tagged with a name, no tag id will be present
532  }
533  }
534  if ( !$tagIds ) {
535  return 0;
536  }
537 
538  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
539 
540  $cond = [
541  'rev_page' => $pageId,
542  $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
543  ];
544  if ( $fromRev ) {
545  $cond[] = $dbr->buildComparison( '>', [
546  'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
547  'rev_id' => $fromRev->getId(),
548  ] );
549  }
550  $edits = $dbr->selectRowCount(
551  [
552  'revision',
553  'change_tag'
554  ],
555  '1',
556  [ 'rev_page' => $pageId ],
557  __METHOD__,
558  [
559  'LIMIT' => self::COUNT_LIMITS['reverted'] + 1, // extra to detect truncation
560  'GROUP BY' => 'rev_id'
561  ],
562  [
563  'change_tag' => [
564  'JOIN',
565  [
566  'ct_rev_id = rev_id',
567  'ct_tag_id' => $tagIds,
568  ]
569  ],
570  ]
571  );
572  return $edits;
573  }
574 
580  protected function getMinorCount( $pageId, RevisionRecord $fromRev = null ) {
581  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
582  $cond = [
583  'rev_page' => $pageId,
584  'rev_minor_edit != 0',
585  $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
586  ];
587  if ( $fromRev ) {
588  $cond[] = $dbr->buildComparison( '>', [
589  'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
590  'rev_id' => $fromRev->getId(),
591  ] );
592  }
593  $edits = $dbr->selectRowCount( 'revision', '1',
594  $cond,
595  __METHOD__,
596  [ 'LIMIT' => self::COUNT_LIMITS['minor'] + 1 ] // extra to detect truncation
597  );
598 
599  return $edits;
600  }
601 
608  protected function getEditsCount(
609  $pageId,
610  RevisionRecord $fromRev = null,
611  RevisionRecord $toRev = null
612  ) {
613  [ $fromRev, $toRev ] = $this->orderRevisions( $fromRev, $toRev );
614  return $this->revisionStore->countRevisionsBetween(
615  $pageId,
616  $fromRev,
617  $toRev,
618  self::COUNT_LIMITS['edits'] // Will be increased by 1 to detect truncation
619  );
620  }
621 
627  private function getRevisionOrThrow( $revId ) {
628  $rev = $this->revisionStore->getRevisionById( $revId );
629  if ( !$rev ) {
630  throw new LocalizedHttpException(
631  new MessageValue( 'rest-nonexistent-revision', [ $revId ] ),
632  404
633  );
634  }
635  return $rev;
636  }
637 
645  private function orderRevisions(
646  RevisionRecord $fromRev = null,
647  RevisionRecord $toRev = null
648  ) {
649  if ( $fromRev && $toRev && ( $fromRev->getTimestamp() > $toRev->getTimestamp() ||
650  ( $fromRev->getTimestamp() === $toRev->getTimestamp()
651  && $fromRev->getId() > $toRev->getId() ) )
652  ) {
653  return [ $toRev, $fromRev ];
654  }
655  return [ $fromRev, $toRev ];
656  }
657 
658  public function needsWriteAccess() {
659  return false;
660  }
661 
662  public function getParamSettings() {
663  return [
664  'title' => [
665  self::PARAM_SOURCE => 'path',
666  ParamValidator::PARAM_TYPE => 'string',
667  ParamValidator::PARAM_REQUIRED => true,
668  ],
669  'type' => [
670  self::PARAM_SOURCE => 'path',
671  ParamValidator::PARAM_TYPE => array_merge(
672  array_keys( self::COUNT_LIMITS ),
673  array_keys( self::DEPRECATED_COUNT_TYPES )
674  ),
675  ParamValidator::PARAM_REQUIRED => true,
676  ],
677  'from' => [
678  self::PARAM_SOURCE => 'query',
679  ParamValidator::PARAM_TYPE => 'integer',
680  ParamValidator::PARAM_REQUIRED => false
681  ],
682  'to' => [
683  self::PARAM_SOURCE => 'query',
684  ParamValidator::PARAM_TYPE => 'integer',
685  ParamValidator::PARAM_REQUIRED => false
686  ]
687  ];
688  }
689 }
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:358
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:17
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