MediaWiki REL1_35
PageHistoryCountHandler.php
Go to the documentation of this file.
1<?php
2
4
15use Title;
16use User;
23
29 private const COUNT_LIMITS = [
30 'anonymous' => 10000,
31 'bot' => 10000,
32 'editors' => 25000,
33 'edits' => 30000,
34 'minor' => 1000,
35 'reverted' => 30000
36 ];
37
38 private const DEPRECATED_COUNT_TYPES = [
39 'anonedits' => 'anonymous',
40 'botedits' => 'bot',
41 'revertededits' => 'reverted'
42 ];
43
44 private const MAX_AGE_200 = 60;
45
46 private const REVERTED_TAG_NAMES = [ 'mw-undo', 'mw-rollback' ];
47
50
53
56
59
61 private $cache;
62
64 private $user;
65
67 private $revision;
68
71
73 private $titleObject;
74
82 public function __construct(
84 NameTableStoreFactory $nameTableStoreFactory,
88 ) {
89 $this->revisionStore = $revisionStore;
90 $this->changeTagDefStore = $nameTableStoreFactory->getChangeTagDef();
91 $this->permissionManager = $permissionManager;
92 $this->loadBalancer = $loadBalancer;
93 $this->cache = $cache;
94
95 // @todo Inject this, when there is a good way to do that
96 $this->user = RequestContext::getMain()->getUser();
97 }
98
99 private function normalizeType( $type ) {
100 return self::DEPRECATED_COUNT_TYPES[$type] ?? $type;
101 }
102
110 $params = $this->getValidatedParams();
111 if ( !$params ) {
112 return;
113 }
114
115 if ( $params['from'] || $params['to'] ) {
116 if ( $type === 'edits' || $type === 'editors' ) {
117 if ( !$params['from'] || !$params['to'] ) {
118 throw new LocalizedHttpException(
119 new MessageValue( 'rest-pagehistorycount-parameters-invalid' ),
120 400
121 );
122 }
123 } else {
124 throw new LocalizedHttpException(
125 new MessageValue( 'rest-pagehistorycount-parameters-invalid' ),
126 400
127 );
128 }
129 }
130 }
131
138 public function run( $title, $type ) {
139 $normalizedType = $this->normalizeType( $type );
140 $this->validateParameterCombination( $normalizedType );
141 $titleObj = $this->getTitle();
142 if ( !$titleObj || !$titleObj->getArticleID() ) {
143 throw new LocalizedHttpException(
144 new MessageValue( 'rest-nonexistent-title',
145 [ new ScalarParam( ParamType::PLAINTEXT, $title ) ]
146 ),
147 404
148 );
149 }
150
151 if ( !$this->permissionManager->userCan( 'read', $this->user, $titleObj ) ) {
152 throw new LocalizedHttpException(
153 new MessageValue( 'rest-permission-denied-title',
154 [ new ScalarParam( ParamType::PLAINTEXT, $title ) ]
155 ),
156 403
157 );
158 }
159
160 $count = $this->getCount( $normalizedType );
161 $countLimit = self::COUNT_LIMITS[$normalizedType];
162 $response = $this->getResponseFactory()->createJson( [
163 'count' => $count > $countLimit ? $countLimit : $count,
164 'limit' => $count > $countLimit
165 ] );
166 $response->setHeader( 'Cache-Control', 'max-age=' . self::MAX_AGE_200 );
167
168 // Inform clients who use a deprecated "type" value, so they can adjust
169 if ( isset( self::DEPRECATED_COUNT_TYPES[$type] ) ) {
170 $docs = '<https://www.mediawiki.org/wiki/API:REST/History_API' .
171 '#Get_page_history_counts>; rel="deprecation"';
172 $response->setHeader( 'Deprecation', 'version="v1"' );
173 $response->setHeader( 'Link', $docs );
174 }
175
176 return $response;
177 }
178
184 private function getCount( $type ) {
185 $pageId = $this->getTitle()->getArticleID();
186 switch ( $type ) {
187 case 'anonymous':
188 return $this->getCachedCount( $type,
189 function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
190 return $this->getAnonCount( $pageId, $fromRev );
191 }
192 );
193
194 case 'bot':
195 return $this->getCachedCount( $type,
196 function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
197 return $this->getBotCount( $pageId, $fromRev );
198 }
199 );
200
201 case 'editors':
202 $from = $this->getValidatedParams()['from'] ?? null;
203 $to = $this->getValidatedParams()['to'] ?? null;
204 if ( $from || $to ) {
205 return $this->getEditorsCount(
206 $pageId,
207 $from ? $this->getRevisionOrThrow( $from ) : null,
208 $to ? $this->getRevisionOrThrow( $to ) : null
209 );
210 } else {
211 return $this->getCachedCount( $type,
212 function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
213 return $this->getEditorsCount( $pageId, $fromRev );
214 } );
215 }
216
217 case 'edits':
218 $from = $this->getValidatedParams()['from'] ?? null;
219 $to = $this->getValidatedParams()['to'] ?? null;
220 if ( $from || $to ) {
221 return $this->getEditsCount(
222 $pageId,
223 $from ? $this->getRevisionOrThrow( $from ) : null,
224 $to ? $this->getRevisionOrThrow( $to ) : null
225 );
226 } else {
227 return $this->getCachedCount( $type,
228 function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
229 return $this->getEditsCount( $pageId, $fromRev );
230 }
231 );
232 }
233
234 case 'reverted':
235 return $this->getCachedCount( $type,
236 function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
237 return $this->getRevertedCount( $pageId, $fromRev );
238 }
239 );
240
241 case 'minor':
242 // The query for minor counts is inefficient for the database for pages with many revisions.
243 // If the specified title contains more revisions than allowed, we will return an error.
244 $editsCount = $this->getCachedCount( 'edits',
245 function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
246 return $this->getEditsCount( $pageId, $fromRev );
247 }
248 );
249 if ( $editsCount > self::COUNT_LIMITS[$type] * 2 ) {
250 throw new LocalizedHttpException(
251 new MessageValue( 'rest-pagehistorycount-too-many-revisions' ),
252 500
253 );
254 }
255 return $this->getCachedCount( $type,
256 function ( RevisionRecord $fromRev = null ) use ( $pageId ) {
257 return $this->getMinorCount( $pageId, $fromRev );
258 }
259 );
260
261 // Sanity check
262 default:
263 throw new LocalizedHttpException(
264 new MessageValue( 'rest-pagehistorycount-type-unrecognized',
265 [ new ScalarParam( ParamType::PLAINTEXT, $type ) ]
266 ),
267 500
268 );
269 }
270 }
271
275 private function getCurrentRevision() {
276 if ( $this->revision === null ) {
277 $title = $this->getTitle();
278 if ( $title && $title->getArticleID() ) {
279 $this->revision = $this->revisionStore->getKnownCurrentRevision( $title );
280 } else {
281 $this->revision = false;
282 }
283 }
284 return $this->revision;
285 }
286
290 private function getTitle() {
291 if ( $this->titleObject === null ) {
292 $this->titleObject = Title::newFromText( $this->getValidatedParams()['title'] );
293 }
294 return $this->titleObject;
295 }
296
303 protected function getLastModified() {
305 if ( $lastModifiedTimes ) {
306 return max( array_values( $lastModifiedTimes ) );
307 }
308 }
309
316 protected function getLastModifiedTimes() {
317 $currentRev = $this->getCurrentRevision();
318 if ( !$currentRev ) {
319 return null;
320 }
321 if ( $this->lastModifiedTimes === null ) {
322 $currentRevTime = (int)wfTimestampOrNull( TS_UNIX, $currentRev->getTimestamp() );
323 $loggingTableTime = $this->loggingTableTime( $currentRev->getPageId() );
324 $this->lastModifiedTimes = [
325 'currentRevTS' => $currentRevTime,
326 'dependencyModTS' => $loggingTableTime
327 ];
328 }
330 }
331
337 private function loggingTableTime( $pageId ) {
338 $res = $this->loadBalancer->getConnectionRef( DB_REPLICA )->selectField(
339 'logging',
340 'MAX(log_timestamp)',
341 [ 'log_page' => $pageId ],
342 __METHOD__
343 );
344 return $res ? (int)wfTimestamp( TS_UNIX, $res ) : null;
345 }
346
357 protected function getEtag() {
358 return null;
359 }
360
366 private function getCachedCount( $type,
367 callable $fetchCount
368 ) {
369 $titleObj = $this->getTitle();
370 $pageId = $titleObj->getArticleID();
371 return $this->cache->getWithSetCallback(
372 $this->cache->makeKey( 'rest', 'pagehistorycount', $pageId, $type ),
373 WANObjectCache::TTL_WEEK,
374 function ( $oldValue ) use ( $fetchCount ) {
375 $currentRev = $this->getCurrentRevision();
376 if ( $oldValue ) {
377 // Last modified timestamp was NOT a dependency change (e.g. revdel)
378 $doIncrementalUpdate = (
379 $this->getLastModified() != $this->getLastModifiedTimes()['dependencyModTS']
380 );
381 if ( $doIncrementalUpdate ) {
382 $rev = $this->revisionStore->getRevisionById( $oldValue['revision'] );
383 if ( $rev ) {
384 $additionalCount = $fetchCount( $rev );
385 return [
386 'revision' => $currentRev->getId(),
387 'count' => $oldValue['count'] + $additionalCount,
388 'dependencyModTS' => $this->getLastModifiedTimes()['dependencyModTS']
389 ];
390 }
391 }
392 }
393 // Nothing was previously stored, or incremental update was done for too long,
394 // recalculate from scratch.
395 return [
396 'revision' => $currentRev->getId(),
397 'count' => $fetchCount(),
398 'dependencyModTS' => $this->getLastModifiedTimes()['dependencyModTS']
399 ];
400 },
401 [
402 'touchedCallback' => function (){
403 return $this->getLastModified();
404 },
405 'version' => 2,
406 'lockTSE' => WANObjectCache::TTL_MINUTE * 5
407 ]
408 )['count'];
409 }
410
416 protected function getAnonCount( $pageId, RevisionRecord $fromRev = null ) {
417 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
418
419 $cond = [
420 'rev_page' => $pageId,
421 'actor_user IS NULL',
422 $dbr->bitAnd( 'rev_deleted',
423 RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) . " = 0"
424 ];
425
426 if ( $fromRev ) {
427 $oldTs = $dbr->addQuotes( $dbr->timestamp( $fromRev->getTimestamp() ) );
428 $cond[] = "(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
429 "OR rev_timestamp > {$oldTs}";
430 }
431
432 $edits = $dbr->selectRowCount(
433 [
434 'revision_actor_temp',
435 'revision',
436 'actor'
437 ],
438 '1',
439 $cond,
440 __METHOD__,
441 [ 'LIMIT' => self::COUNT_LIMITS['anonymous'] + 1 ], // extra to detect truncation
442 [
443 'revision' => [
444 'JOIN',
445 'revactor_rev = rev_id AND revactor_page = rev_page'
446 ],
447 'actor' => [
448 'JOIN',
449 'revactor_actor = actor_id'
450 ]
451 ]
452 );
453 return $edits;
454 }
455
461 protected function getBotCount( $pageId, RevisionRecord $fromRev = null ) {
462 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
463
464 $cond = [
465 'rev_page=' . intval( $pageId ),
466 $dbr->bitAnd( 'rev_deleted',
467 RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) . " = 0",
468 'EXISTS(' .
469 $dbr->selectSQLText(
470 'user_groups',
471 '1',
472 [
473 'actor.actor_user = ug_user',
474 'ug_group' => $this->permissionManager->getGroupsWithPermission( 'bot' ),
475 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() )
476 ],
477 __METHOD__
478 ) .
479 ')'
480 ];
481 if ( $fromRev ) {
482 $oldTs = $dbr->addQuotes( $dbr->timestamp( $fromRev->getTimestamp() ) );
483 $cond[] = "(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
484 "OR rev_timestamp > {$oldTs}";
485 }
486
487 $edits = $dbr->selectRowCount(
488 [
489 'revision_actor_temp',
490 'revision',
491 'actor',
492 ],
493 '1',
494 $cond,
495 __METHOD__,
496 [ 'LIMIT' => self::COUNT_LIMITS['bot'] + 1 ], // extra to detect truncation
497 [
498 'revision' => [
499 'JOIN',
500 'revactor_rev = rev_id AND revactor_page = rev_page'
501 ],
502 'actor' => [
503 'JOIN',
504 'revactor_actor = actor_id'
505 ],
506 ]
507 );
508 return $edits;
509 }
510
517 protected function getEditorsCount( $pageId,
518 RevisionRecord $fromRev = null,
519 RevisionRecord $toRev = null
520 ) {
521 list( $fromRev, $toRev ) = $this->orderRevisions( $fromRev, $toRev );
522 return $this->revisionStore->countAuthorsBetween( $pageId, $fromRev,
523 $toRev, $this->user, self::COUNT_LIMITS['editors'] );
524 }
525
531 protected function getRevertedCount( $pageId, RevisionRecord $fromRev = null ) {
532 $tagIds = [];
533
534 foreach ( self::REVERTED_TAG_NAMES as $tagName ) {
535 try {
536 $tagIds[] = $this->changeTagDefStore->getId( $tagName );
537 } catch ( NameTableAccessException $e ) {
538 // If no revisions are tagged with a name, no tag id will be present
539 }
540 }
541 if ( !$tagIds ) {
542 return 0;
543 }
544
545 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
546
547 $cond = [
548 'rev_page' => $pageId,
549 $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
550 ];
551 if ( $fromRev ) {
552 $oldTs = $dbr->addQuotes( $dbr->timestamp( $fromRev->getTimestamp() ) );
553 $cond[] = "(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
554 "OR rev_timestamp > {$oldTs}";
555 }
556 $edits = $dbr->selectRowCount(
557 [
558 'revision',
559 'change_tag'
560 ],
561 '1',
562 [ 'rev_page' => $pageId ],
563 __METHOD__,
564 [
565 'LIMIT' => self::COUNT_LIMITS['reverted'] + 1, // extra to detect truncation
566 'GROUP BY' => 'rev_id'
567 ],
568 [
569 'change_tag' => [
570 'JOIN',
571 [
572 'ct_rev_id = rev_id',
573 'ct_tag_id' => $tagIds,
574 ]
575 ],
576 ]
577 );
578 return $edits;
579 }
580
586 protected function getMinorCount( $pageId, RevisionRecord $fromRev = null ) {
587 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
588 $cond = [
589 'rev_page' => $pageId,
590 'rev_minor_edit != 0',
591 $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
592 ];
593 if ( $fromRev ) {
594 $oldTs = $dbr->addQuotes( $dbr->timestamp( $fromRev->getTimestamp() ) );
595 $cond[] = "(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
596 "OR rev_timestamp > {$oldTs}";
597 }
598 $edits = $dbr->selectRowCount( 'revision', '1',
599 $cond,
600 __METHOD__,
601 [ 'LIMIT' => self::COUNT_LIMITS['minor'] + 1 ] // extra to detect truncation
602 );
603
604 return $edits;
605 }
606
613 protected function getEditsCount(
614 $pageId,
615 RevisionRecord $fromRev = null,
616 RevisionRecord $toRev = null
617 ) {
618 list( $fromRev, $toRev ) = $this->orderRevisions( $fromRev, $toRev );
619 return $this->revisionStore->countRevisionsBetween(
620 $pageId,
621 $fromRev,
622 $toRev,
623 self::COUNT_LIMITS['edits'] // Will be increased by 1 to detect truncation
624 );
625 }
626
632 private function getRevisionOrThrow( $revId ) {
633 $rev = $this->revisionStore->getRevisionById( $revId );
634 if ( !$rev ) {
635 throw new LocalizedHttpException(
636 new MessageValue( 'rest-nonexistent-revision', [ $revId ] ),
637 404
638 );
639 }
640 return $rev;
641 }
642
650 private function orderRevisions(
651 RevisionRecord $fromRev = null,
652 RevisionRecord $toRev = null
653 ) {
654 if ( $fromRev && $toRev && ( $fromRev->getTimestamp() > $toRev->getTimestamp() ||
655 ( $fromRev->getTimestamp() === $toRev->getTimestamp()
656 && $fromRev->getId() > $toRev->getId() ) )
657 ) {
658 return [ $toRev, $fromRev ];
659 }
660 return [ $fromRev, $toRev ];
661 }
662
663 public function needsWriteAccess() {
664 return false;
665 }
666
667 public function getParamSettings() {
668 return [
669 'title' => [
670 self::PARAM_SOURCE => 'path',
671 ParamValidator::PARAM_TYPE => 'string',
672 ParamValidator::PARAM_REQUIRED => true,
673 ],
674 'type' => [
675 self::PARAM_SOURCE => 'path',
676 ParamValidator::PARAM_TYPE => array_merge(
677 array_keys( self::COUNT_LIMITS ),
678 array_keys( self::DEPRECATED_COUNT_TYPES )
679 ),
680 ParamValidator::PARAM_REQUIRED => true,
681 ],
682 'from' => [
683 self::PARAM_SOURCE => 'query',
684 ParamValidator::PARAM_TYPE => 'integer',
685 ParamValidator::PARAM_REQUIRED => false
686 ],
687 'to' => [
688 self::PARAM_SOURCE => 'query',
689 ParamValidator::PARAM_TYPE => 'integer',
690 ParamValidator::PARAM_REQUIRED => false
691 ]
692 ];
693 }
694}
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.
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
Handler class for Core REST API endpoints that perform operations on revisions.
validateParameterCombination( $type)
Validates that the provided parameter combination is supported.
getAnonCount( $pageId, RevisionRecord $fromRev=null)
loggingTableTime( $pageId)
Return timestamp of latest entry in logging table for given page id.
getMinorCount( $pageId, RevisionRecord $fromRev=null)
getEtag()
Choosing to not implement etags in this handler.
__construct(RevisionStore $revisionStore, NameTableStoreFactory $nameTableStoreFactory, PermissionManager $permissionManager, ILoadBalancer $loadBalancer, WANObjectCache $cache)
orderRevisions(RevisionRecord $fromRev=null, RevisionRecord $toRev=null)
Reorders revisions if they are present.
const COUNT_LIMITS
The maximum number of counts to return per type of revision.
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:257
getResponseFactory()
Get the ResponseFactory which can be used to generate Response objects.
Definition Handler.php:151
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.
Group all the pieces relevant to the context of a request into one instance @newable.
Represents a title within MediaWiki.
Definition Title.php:42
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition User.php:60
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.
Service for formatting and validating API parameters.
Database cluster connection, tracking, load balancing, and transaction manager interface.
const DB_REPLICA
Definition defines.php:25