MediaWiki REL1_37
PageHistoryCountHandler.php
Go to the documentation of this file.
1<?php
2
4
6use ChangeTags;
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
49
52
55
58
60 private $pageLookup;
61
63 private $cache;
64
67
69 private $revision = false;
70
73
75 private $page = false;
76
86 public function __construct(
88 NameTableStoreFactory $nameTableStoreFactory,
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
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 // Sanity check
267 default:
268 throw new LocalizedHttpException(
269 new MessageValue( 'rest-pagehistorycount-type-unrecognized',
270 [ new ScalarParam( ParamType::PLAINTEXT, $type ) ]
271 ),
272 500
273 );
274 }
275 }
276
280 private function getCurrentRevision(): ?RevisionRecord {
281 if ( $this->revision === false ) {
282 $page = $this->getPage();
283 if ( $page ) {
284 $this->revision = $this->revisionStore->getKnownCurrentRevision( $page ) ?: null;
285 } else {
286 $this->revision = null;
287 }
288 }
289 return $this->revision;
290 }
291
295 private function getPage(): ?ExistingPageRecord {
296 if ( $this->page === false ) {
297 $this->page = $this->pageLookup->getExistingPageByText(
298 $this->getValidatedParams()['title']
299 );
300 }
301 return $this->page;
302 }
303
310 protected function getLastModified() {
311 $lastModifiedTimes = $this->getLastModifiedTimes();
312 if ( $lastModifiedTimes ) {
313 return max( array_values( $lastModifiedTimes ) );
314 }
315 return null;
316 }
317
324 protected function getLastModifiedTimes() {
325 $currentRev = $this->getCurrentRevision();
326 if ( !$currentRev ) {
327 return null;
328 }
329 if ( $this->lastModifiedTimes === null ) {
330 $currentRevTime = (int)wfTimestampOrNull( TS_UNIX, $currentRev->getTimestamp() );
331 $loggingTableTime = $this->loggingTableTime( $currentRev->getPageId() );
332 $this->lastModifiedTimes = [
333 'currentRevTS' => $currentRevTime,
334 'dependencyModTS' => $loggingTableTime
335 ];
336 }
337 return $this->lastModifiedTimes;
338 }
339
345 private function loggingTableTime( $pageId ) {
346 $res = $this->loadBalancer->getConnectionRef( DB_REPLICA )->selectField(
347 'logging',
348 'MAX(log_timestamp)',
349 [ 'log_page' => $pageId ],
350 __METHOD__
351 );
352 return $res ? (int)wfTimestamp( TS_UNIX, $res ) : null;
353 }
354
365 protected function getEtag() {
366 return null;
367 }
368
374 private function getCachedCount( $type,
375 callable $fetchCount
376 ) {
377 $pageId = $this->getPage()->getId();
378 return $this->cache->getWithSetCallback(
379 $this->cache->makeKey( 'rest', 'pagehistorycount', $pageId, $type ),
380 WANObjectCache::TTL_WEEK,
381 function ( $oldValue ) use ( $fetchCount ) {
382 $currentRev = $this->getCurrentRevision();
383 if ( $oldValue ) {
384 // Last modified timestamp was NOT a dependency change (e.g. revdel)
385 $doIncrementalUpdate = (
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 'dependencyModTS' => $this->getLastModifiedTimes()['dependencyModTS']
396 ];
397 }
398 }
399 }
400 // Nothing was previously stored, or incremental update was done for too long,
401 // recalculate from scratch.
402 return [
403 'revision' => $currentRev->getId(),
404 'count' => $fetchCount(),
405 'dependencyModTS' => $this->getLastModifiedTimes()['dependencyModTS']
406 ];
407 },
408 [
409 'touchedCallback' => function (){
410 return $this->getLastModified();
411 },
412 'version' => 2,
413 'lockTSE' => WANObjectCache::TTL_MINUTE * 5
414 ]
415 )['count'];
416 }
417
423 protected function getAnonCount( $pageId, RevisionRecord $fromRev = null ) {
424 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
425
426 $revQuery = $this->actorMigration->getJoin( 'rev_user' );
427
428 $cond = [
429 'rev_page' => $pageId,
430 'actor_user IS NULL',
431 $dbr->bitAnd( 'rev_deleted',
432 RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) . " = 0"
433 ];
434
435 if ( $fromRev ) {
436 $oldTs = $dbr->addQuotes( $dbr->timestamp( $fromRev->getTimestamp() ) );
437 $cond[] = "(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
438 "OR rev_timestamp > {$oldTs}";
439 }
440
441 // This redundant join condition tells MySQL that rev_page and revactor_page are the
442 // same, so it can propagate the condition
443 if ( isset( $revQuery['tables']['temp_rev_user'] ) /* SCHEMA_COMPAT_READ_TEMP */ ) {
444 $revQuery['joins']['temp_rev_user'][1] =
445 "temp_rev_user.revactor_rev = rev_id AND revactor_page = rev_page";
446 }
447
448 $edits = $dbr->selectRowCount(
449 [
450 'revision',
451 ] + $revQuery['tables'],
452 '1',
453 $cond,
454 __METHOD__,
455 [ 'LIMIT' => self::COUNT_LIMITS['anonymous'] + 1 ], // extra to detect truncation
456 $revQuery['joins']
457 );
458 return $edits;
459 }
460
466 protected function getBotCount( $pageId, RevisionRecord $fromRev = null ) {
467 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
468
469 $revQuery = $this->actorMigration->getJoin( 'rev_user' );
470
471 // This redundant join condition tells MySQL that rev_page and revactor_page are the
472 // same, so it can propagate the condition
473 if ( isset( $revQuery['tables']['temp_rev_user'] ) /* SCHEMA_COMPAT_READ_TEMP */ ) {
474 $revQuery['joins']['temp_rev_user'][1] =
475 "temp_rev_user.revactor_rev = rev_id AND revactor_page = rev_page";
476 }
477
478 $cond = [
479 'rev_page=' . intval( $pageId ),
480 $dbr->bitAnd( 'rev_deleted',
481 RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) . " = 0",
482 'EXISTS(' .
483 $dbr->selectSQLText(
484 'user_groups',
485 '1',
486 [
487 $revQuery['fields']['rev_user'] . ' = ug_user',
488 'ug_group' => $this->groupPermissionsLookup->getGroupsWithPermission( 'bot' ),
489 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() )
490 ],
491 __METHOD__
492 ) .
493 ')'
494 ];
495 if ( $fromRev ) {
496 $oldTs = $dbr->addQuotes( $dbr->timestamp( $fromRev->getTimestamp() ) );
497 $cond[] = "(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
498 "OR rev_timestamp > {$oldTs}";
499 }
500
501 $edits = $dbr->selectRowCount(
502 [
503 'revision',
504 ] + $revQuery['tables'],
505 '1',
506 $cond,
507 __METHOD__,
508 [ 'LIMIT' => self::COUNT_LIMITS['bot'] + 1 ], // extra to detect truncation
509 $revQuery['joins']
510 );
511 return $edits;
512 }
513
520 protected function getEditorsCount( $pageId,
521 RevisionRecord $fromRev = null,
522 RevisionRecord $toRev = null
523 ) {
524 list( $fromRev, $toRev ) = $this->orderRevisions( $fromRev, $toRev );
525 return $this->revisionStore->countAuthorsBetween( $pageId, $fromRev,
526 $toRev, $this->getAuthority(), self::COUNT_LIMITS['editors'] );
527 }
528
534 protected function getRevertedCount( $pageId, RevisionRecord $fromRev = null ) {
535 $tagIds = [];
536
537 foreach ( ChangeTags::REVERT_TAGS as $tagName ) {
538 try {
539 $tagIds[] = $this->changeTagDefStore->getId( $tagName );
540 } catch ( NameTableAccessException $e ) {
541 // If no revisions are tagged with a name, no tag id will be present
542 }
543 }
544 if ( !$tagIds ) {
545 return 0;
546 }
547
548 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
549
550 $cond = [
551 'rev_page' => $pageId,
552 $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
553 ];
554 if ( $fromRev ) {
555 $oldTs = $dbr->addQuotes( $dbr->timestamp( $fromRev->getTimestamp() ) );
556 $cond[] = "(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
557 "OR rev_timestamp > {$oldTs}";
558 }
559 $edits = $dbr->selectRowCount(
560 [
561 'revision',
562 'change_tag'
563 ],
564 '1',
565 [ 'rev_page' => $pageId ],
566 __METHOD__,
567 [
568 'LIMIT' => self::COUNT_LIMITS['reverted'] + 1, // extra to detect truncation
569 'GROUP BY' => 'rev_id'
570 ],
571 [
572 'change_tag' => [
573 'JOIN',
574 [
575 'ct_rev_id = rev_id',
576 'ct_tag_id' => $tagIds,
577 ]
578 ],
579 ]
580 );
581 return $edits;
582 }
583
589 protected function getMinorCount( $pageId, RevisionRecord $fromRev = null ) {
590 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
591 $cond = [
592 'rev_page' => $pageId,
593 'rev_minor_edit != 0',
594 $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
595 ];
596 if ( $fromRev ) {
597 $oldTs = $dbr->addQuotes( $dbr->timestamp( $fromRev->getTimestamp() ) );
598 $cond[] = "(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
599 "OR rev_timestamp > {$oldTs}";
600 }
601 $edits = $dbr->selectRowCount( 'revision', '1',
602 $cond,
603 __METHOD__,
604 [ 'LIMIT' => self::COUNT_LIMITS['minor'] + 1 ] // extra to detect truncation
605 );
606
607 return $edits;
608 }
609
616 protected function getEditsCount(
617 $pageId,
618 RevisionRecord $fromRev = null,
619 RevisionRecord $toRev = null
620 ) {
621 list( $fromRev, $toRev ) = $this->orderRevisions( $fromRev, $toRev );
622 return $this->revisionStore->countRevisionsBetween(
623 $pageId,
624 $fromRev,
625 $toRev,
626 self::COUNT_LIMITS['edits'] // Will be increased by 1 to detect truncation
627 );
628 }
629
635 private function getRevisionOrThrow( $revId ) {
636 $rev = $this->revisionStore->getRevisionById( $revId );
637 if ( !$rev ) {
638 throw new LocalizedHttpException(
639 new MessageValue( 'rest-nonexistent-revision', [ $revId ] ),
640 404
641 );
642 }
643 return $rev;
644 }
645
653 private function orderRevisions(
654 RevisionRecord $fromRev = null,
655 RevisionRecord $toRev = null
656 ) {
657 if ( $fromRev && $toRev && ( $fromRev->getTimestamp() > $toRev->getTimestamp() ||
658 ( $fromRev->getTimestamp() === $toRev->getTimestamp()
659 && $fromRev->getId() > $toRev->getId() ) )
660 ) {
661 return [ $toRev, $fromRev ];
662 }
663 return [ $fromRev, $toRev ];
664 }
665
666 public function needsWriteAccess() {
667 return false;
668 }
669
670 public function getParamSettings() {
671 return [
672 'title' => [
673 self::PARAM_SOURCE => 'path',
674 ParamValidator::PARAM_TYPE => 'string',
675 ParamValidator::PARAM_REQUIRED => true,
676 ],
677 'type' => [
678 self::PARAM_SOURCE => 'path',
679 ParamValidator::PARAM_TYPE => array_merge(
680 array_keys( self::COUNT_LIMITS ),
681 array_keys( self::DEPRECATED_COUNT_TYPES )
682 ),
683 ParamValidator::PARAM_REQUIRED => true,
684 ],
685 'from' => [
686 self::PARAM_SOURCE => 'query',
687 ParamValidator::PARAM_TYPE => 'integer',
688 ParamValidator::PARAM_REQUIRED => false
689 ],
690 'to' => [
691 self::PARAM_SOURCE => 'query',
692 ParamValidator::PARAM_TYPE => 'integer',
693 ParamValidator::PARAM_REQUIRED => false
694 ]
695 ];
696 }
697}
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(ini_get('mbstring.func_overload')) if(!defined('MW_ENTRY_POINT'))
Pre-config setup: Before loading LocalSettings.php.
Definition Setup.php:88
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.
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)
__construct(RevisionStore $revisionStore, NameTableStoreFactory $nameTableStoreFactory, GroupPermissionsLookup $groupPermissionsLookup, ILoadBalancer $loadBalancer, WANObjectCache $cache, PageLookup $pageLookup, ActorMigration $actorMigration)
getEtag()
Choosing to not implement etags in this handler.
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:282
getAuthority()
Get the current acting authority.
Definition Handler.php:148
getResponseFactory()
Get the ResponseFactory which can be used to generate Response objects.
Definition Handler.php:170
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.
Service for formatting and validating API parameters.
Data record representing a page that currently exists as an editable page on a wiki.
Service interface for looking up infermation about wiki pages.
Database cluster connection, tracking, load balancing, and transaction manager interface.
const DB_REPLICA
Definition defines.php:25