30 private const COUNT_LIMITS = [
39 private const DEPRECATED_COUNT_TYPES = [
40 'anonedits' =>
'anonymous',
42 'revertededits' =>
'reverted'
45 private const MAX_AGE_200 = 60;
48 private $revisionStore;
51 private $changeTagDefStore;
54 private $groupPermissionsLookup;
57 private $loadBalancer;
66 private $actorMigration;
69 private $revision =
false;
72 private $lastModifiedTimes;
75 private $page =
false;
95 $this->revisionStore = $revisionStore;
97 $this->groupPermissionsLookup = $groupPermissionsLookup;
98 $this->loadBalancer = $loadBalancer;
100 $this->pageLookup = $pageLookup;
101 $this->actorMigration = $actorMigration;
104 private function normalizeType(
$type ) {
105 return self::DEPRECATED_COUNT_TYPES[
$type] ??
$type;
114 private function validateParameterCombination(
$type ) {
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' ),
129 throw new LocalizedHttpException(
130 new MessageValue(
'rest-pagehistorycount-parameters-invalid' ),
144 $normalizedType = $this->normalizeType(
$type );
145 $this->validateParameterCombination( $normalizedType );
146 $page = $this->getPage();
156 if ( !$this->
getAuthority()->authorizeRead(
'read', $page ) ) {
165 $count = $this->getCount( $normalizedType );
166 $countLimit = self::COUNT_LIMITS[$normalizedType];
168 'count' => $count > $countLimit ? $countLimit : $count,
169 'limit' => $count > $countLimit
171 $response->setHeader(
'Cache-Control',
'max-age=' . self::MAX_AGE_200 );
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 );
189 private function getCount(
$type ) {
190 $pageId = $this->getPage()->getId();
193 return $this->getCachedCount(
$type,
200 return $this->getCachedCount(
$type,
209 if ( $from || $to ) {
212 $from ? $this->getRevisionOrThrow( $from ) : null,
213 $to ? $this->getRevisionOrThrow( $to ) : null
216 return $this->getCachedCount(
$type,
217 function ( RevisionRecord $fromRev =
null ) use ( $pageId ) {
225 if ( $from || $to ) {
228 $from ? $this->getRevisionOrThrow( $from ) : null,
229 $to ? $this->getRevisionOrThrow( $to ) : null
232 return $this->getCachedCount(
$type,
233 function ( RevisionRecord $fromRev =
null ) use ( $pageId ) {
240 return $this->getCachedCount(
$type,
241 function ( RevisionRecord $fromRev =
null ) use ( $pageId ) {
249 $editsCount = $this->getCachedCount(
'edits',
250 function ( RevisionRecord $fromRev =
null ) use ( $pageId ) {
254 if ( $editsCount > self::COUNT_LIMITS[
$type] * 2 ) {
255 throw new LocalizedHttpException(
256 new MessageValue(
'rest-pagehistorycount-too-many-revisions' ),
260 return $this->getCachedCount(
$type,
261 function ( RevisionRecord $fromRev =
null ) use ( $pageId ) {
267 throw new LocalizedHttpException(
268 new MessageValue(
'rest-pagehistorycount-type-unrecognized',
269 [
new ScalarParam( ParamType::PLAINTEXT,
$type ) ]
279 private function getCurrentRevision(): ?RevisionRecord {
280 if ( $this->revision === false ) {
281 $page = $this->getPage();
283 $this->revision = $this->revisionStore->getKnownCurrentRevision( $page ) ?:
null;
285 $this->revision =
null;
288 return $this->revision;
294 private function getPage(): ?ExistingPageRecord {
295 if ( $this->page === false ) {
296 $this->page = $this->pageLookup->getExistingPageByText(
310 $lastModifiedTimes = $this->getLastModifiedTimes();
311 if ( $lastModifiedTimes ) {
312 return max( array_values( $lastModifiedTimes ) );
324 $currentRev = $this->getCurrentRevision();
325 if ( !$currentRev ) {
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
336 return $this->lastModifiedTimes;
344 private function loggingTableTime( $pageId ) {
345 $res = $this->loadBalancer->getConnectionRef(
DB_REPLICA )->selectField(
347 'MAX(log_timestamp)',
348 [
'log_page' => $pageId ],
373 private function getCachedCount(
$type,
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();
384 $doIncrementalUpdate = (
386 $this->getLastModified() != $this->getLastModifiedTimes()[
'dependencyModTS']
388 if ( $doIncrementalUpdate ) {
389 $rev = $this->revisionStore->getRevisionById( $oldValue[
'revision'] );
391 $additionalCount = $fetchCount( $rev );
393 'revision' => $currentRev->getId(),
394 'count' => $oldValue[
'count'] + $additionalCount,
396 'dependencyModTS' => $this->getLastModifiedTimes()[
'dependencyModTS']
404 'revision' => $currentRev->getId(),
405 'count' => $fetchCount(),
407 'dependencyModTS' => $this->getLastModifiedTimes()[
'dependencyModTS']
411 'touchedCallback' =>
function (){
412 return $this->getLastModified();
415 'lockTSE' => WANObjectCache::TTL_MINUTE * 5
428 $revQuery = $this->actorMigration->getJoin(
'rev_user' );
431 'rev_page' => $pageId,
432 'actor_user IS NULL',
433 $dbr->bitAnd(
'rev_deleted',
434 RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) .
" = 0"
438 $oldTs =
$dbr->addQuotes(
$dbr->timestamp( $fromRev->getTimestamp() ) );
439 $cond[] =
"(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
440 "OR rev_timestamp > {$oldTs}";
443 $edits =
$dbr->selectRowCount(
450 [
'LIMIT' => self::COUNT_LIMITS[
'anonymous'] + 1 ],
464 $revQuery = $this->actorMigration->getJoin(
'rev_user' );
468 if ( isset(
$revQuery[
'tables'][
'temp_rev_user'] ) ) {
470 "temp_rev_user.revactor_rev = rev_id AND revactor_page = rev_page";
474 'rev_page=' . intval( $pageId ),
475 $dbr->bitAnd(
'rev_deleted',
476 RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) .
" = 0",
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() )
491 $oldTs =
$dbr->addQuotes(
$dbr->timestamp( $fromRev->getTimestamp() ) );
492 $cond[] =
"(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
493 "OR rev_timestamp > {$oldTs}";
496 $edits =
$dbr->selectRowCount(
504 [
'LIMIT' => self::COUNT_LIMITS[
'bot'] + 1 ],
520 list( $fromRev, $toRev ) = $this->orderRevisions( $fromRev, $toRev );
521 return $this->revisionStore->countAuthorsBetween( $pageId, $fromRev,
522 $toRev, $this->
getAuthority(), self::COUNT_LIMITS[
'editors'] );
535 $tagIds[] = $this->changeTagDefStore->getId( $tagName );
547 'rev_page' => $pageId,
548 $dbr->bitAnd(
'rev_deleted', RevisionRecord::DELETED_TEXT ) .
" = 0"
551 $oldTs =
$dbr->addQuotes(
$dbr->timestamp( $fromRev->getTimestamp() ) );
552 $cond[] =
"(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
553 "OR rev_timestamp > {$oldTs}";
555 $edits =
$dbr->selectRowCount(
561 [
'rev_page' => $pageId ],
564 'LIMIT' => self::COUNT_LIMITS[
'reverted'] + 1,
565 'GROUP BY' =>
'rev_id'
571 'ct_rev_id = rev_id',
572 'ct_tag_id' => $tagIds,
588 'rev_page' => $pageId,
589 'rev_minor_edit != 0',
590 $dbr->bitAnd(
'rev_deleted', RevisionRecord::DELETED_TEXT ) .
" = 0"
593 $oldTs =
$dbr->addQuotes(
$dbr->timestamp( $fromRev->getTimestamp() ) );
594 $cond[] =
"(rev_timestamp = {$oldTs} AND rev_id > {$fromRev->getId()}) " .
595 "OR rev_timestamp > {$oldTs}";
597 $edits =
$dbr->selectRowCount(
'revision',
'1',
600 [
'LIMIT' => self::COUNT_LIMITS[
'minor'] + 1 ]
617 list( $fromRev, $toRev ) = $this->orderRevisions( $fromRev, $toRev );
618 return $this->revisionStore->countRevisionsBetween(
622 self::COUNT_LIMITS[
'edits']
631 private function getRevisionOrThrow( $revId ) {
632 $rev = $this->revisionStore->getRevisionById( $revId );
635 new MessageValue(
'rest-nonexistent-revision', [ $revId ] ),
649 private function orderRevisions(
653 if ( $fromRev && $toRev && ( $fromRev->getTimestamp() > $toRev->getTimestamp() ||
654 ( $fromRev->getTimestamp() === $toRev->getTimestamp()
655 && $fromRev->getId() > $toRev->getId() ) )
657 return [ $toRev, $fromRev ];
659 return [ $fromRev, $toRev ];
669 self::PARAM_SOURCE =>
'path',
670 ParamValidator::PARAM_TYPE =>
'string',
671 ParamValidator::PARAM_REQUIRED =>
true,
674 self::PARAM_SOURCE =>
'path',
675 ParamValidator::PARAM_TYPE => array_merge(
676 array_keys( self::COUNT_LIMITS ),
677 array_keys( self::DEPRECATED_COUNT_TYPES )
679 ParamValidator::PARAM_REQUIRED =>
true,
682 self::PARAM_SOURCE =>
'query',
683 ParamValidator::PARAM_TYPE =>
'integer',
684 ParamValidator::PARAM_REQUIRED => false
687 self::PARAM_SOURCE =>
'query',
688 ParamValidator::PARAM_TYPE =>
'integer',
689 ParamValidator::PARAM_REQUIRED => false
__construct(RevisionStore $revisionStore, NameTableStoreFactory $nameTableStoreFactory, GroupPermissionsLookup $groupPermissionsLookup, ILoadBalancer $loadBalancer, WANObjectCache $cache, PageLookup $pageLookup, ActorMigration $actorMigration)