MediaWiki REL1_39
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
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.
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)
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.
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.
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