Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 374
0.00% covered (danger)
0.00%
0 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageHistoryCountHandler
0.00% covered (danger)
0.00%
0 / 374
0.00% covered (danger)
0.00%
0 / 26
6642
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getRedirectHelper
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 normalizeType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateParameterCombination
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
72
 run
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
42
 getCount
0.00% covered (danger)
0.00%
0 / 73
0.00% covered (danger)
0.00%
0 / 1
342
 getCurrentRevision
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 getPage
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getLastModified
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getLastModifiedTimes
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 loggingTableTime
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getEtag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCachedCount
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 getAnonCount
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 getTempCount
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
12
 getBotCount
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
6
 getEditorsCount
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getRevertedCount
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
30
 getMinorCount
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getEditsCount
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getRevisionOrThrow
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 orderRevisions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
42
 needsWriteAccess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getResponseBodySchemaFileName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getParamSettings
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
2
 getResponseHeaderSettings
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Rest\Handler;
4
5use MediaWiki\ChangeTags\ChangeTags;
6use MediaWiki\Page\ExistingPageRecord;
7use MediaWiki\Page\PageLookup;
8use MediaWiki\Permissions\GroupPermissionsLookup;
9use MediaWiki\Rest\Handler;
10use MediaWiki\Rest\Handler\Helper\PageRedirectHelper;
11use MediaWiki\Rest\Handler\Helper\PageRestHelperFactory;
12use MediaWiki\Rest\LocalizedHttpException;
13use MediaWiki\Rest\Response;
14use MediaWiki\Rest\ResponseHeaders;
15use MediaWiki\Rest\SimpleHandler;
16use MediaWiki\Revision\RevisionRecord;
17use MediaWiki\Revision\RevisionStore;
18use MediaWiki\Storage\NameTableAccessException;
19use MediaWiki\Storage\NameTableStore;
20use MediaWiki\Storage\NameTableStoreFactory;
21use MediaWiki\User\TempUser\TempUserConfig;
22use Wikimedia\Message\MessageValue;
23use Wikimedia\ObjectCache\WANObjectCache;
24use Wikimedia\ParamValidator\ParamValidator;
25use Wikimedia\Rdbms\IConnectionProvider;
26use Wikimedia\Rdbms\IExpression;
27use Wikimedia\Rdbms\RawSQLExpression;
28use Wikimedia\Timestamp\TimestampFormat as TS;
29
30/**
31 * Handler class for Core REST API endpoints that perform operations on revisions
32 */
33class PageHistoryCountHandler extends SimpleHandler {
34
35    /** The maximum number of counts to return per type of revision */
36    private const COUNT_LIMITS = [
37        'anonymous' => 10000,
38        'temporary' => 10000,
39        'bot' => 10000,
40        'editors' => 25000,
41        'edits' => 30000,
42        'minor' => 1000,
43        'reverted' => 30000
44    ];
45
46    private const DEPRECATED_COUNT_TYPES = [
47        'anonedits' => 'anonymous',
48        'botedits' => 'bot',
49        'revertededits' => 'reverted'
50    ];
51
52    private const MAX_AGE_200 = 60;
53
54    private RevisionStore $revisionStore;
55    private NameTableStore $changeTagDefStore;
56    private GroupPermissionsLookup $groupPermissionsLookup;
57    private IConnectionProvider $dbProvider;
58    private PageLookup $pageLookup;
59    private WANObjectCache $cache;
60    private PageRestHelperFactory $helperFactory;
61    private TempUserConfig $tempUserConfig;
62
63    /** @var RevisionRecord|false|null */
64    private $revision = false;
65
66    /** @var array|null */
67    private $lastModifiedTimes;
68
69    /** @var ExistingPageRecord|false|null */
70    private $page = false;
71
72    /**
73     * @param RevisionStore $revisionStore
74     * @param NameTableStoreFactory $nameTableStoreFactory
75     * @param GroupPermissionsLookup $groupPermissionsLookup
76     * @param IConnectionProvider $dbProvider
77     * @param WANObjectCache $cache
78     * @param PageLookup $pageLookup
79     * @param PageRestHelperFactory $helperFactory
80     * @param TempUserConfig $tempUserConfig
81     */
82    public function __construct(
83        RevisionStore $revisionStore,
84        NameTableStoreFactory $nameTableStoreFactory,
85        GroupPermissionsLookup $groupPermissionsLookup,
86        IConnectionProvider $dbProvider,
87        WANObjectCache $cache,
88        PageLookup $pageLookup,
89        PageRestHelperFactory $helperFactory,
90        TempUserConfig $tempUserConfig
91    ) {
92        $this->revisionStore = $revisionStore;
93        $this->changeTagDefStore = $nameTableStoreFactory->getChangeTagDef();
94        $this->groupPermissionsLookup = $groupPermissionsLookup;
95        $this->dbProvider = $dbProvider;
96        $this->cache = $cache;
97        $this->pageLookup = $pageLookup;
98        $this->helperFactory = $helperFactory;
99        $this->tempUserConfig = $tempUserConfig;
100    }
101
102    private function getRedirectHelper(): PageRedirectHelper {
103        return $this->helperFactory->newPageRedirectHelper(
104            $this->getResponseFactory(),
105            $this->getRouter(),
106            $this->getPath(),
107            $this->getRequest()
108        );
109    }
110
111    private function normalizeType( string $type ): string {
112        return self::DEPRECATED_COUNT_TYPES[$type] ?? $type;
113    }
114
115    /**
116     * Validates that the provided parameter combination is supported.
117     *
118     * @param string $type
119     * @throws LocalizedHttpException
120     */
121    private function validateParameterCombination( $type ) {
122        $params = $this->getValidatedParams();
123        if ( !$params ) {
124            return;
125        }
126
127        if ( $params['from'] || $params['to'] ) {
128            if ( $type === 'edits' || $type === 'editors' ) {
129                if ( !$params['from'] || !$params['to'] ) {
130                    throw new LocalizedHttpException(
131                        new MessageValue( 'rest-pagehistorycount-parameters-invalid' ),
132                        400
133                    );
134                }
135            } else {
136                throw new LocalizedHttpException(
137                    new MessageValue( 'rest-pagehistorycount-parameters-invalid' ),
138                    400
139                );
140            }
141        }
142    }
143
144    /**
145     * @param string $title the title of the page to load history for
146     * @param string $type the validated count type
147     * @return Response
148     * @throws LocalizedHttpException
149     */
150    public function run( $title, $type ) {
151        $normalizedType = $this->normalizeType( $type );
152        $this->validateParameterCombination( $normalizedType );
153        $params = $this->getValidatedParams();
154        $page = $this->getPage();
155
156        if ( !$page ) {
157            throw new LocalizedHttpException(
158                ( new MessageValue( 'rest-nonexistent-title' ) )->plaintextParams( $title ),
159                404
160            );
161        }
162
163        if ( !$this->getAuthority()->authorizeRead( 'read', $page ) ) {
164            throw new LocalizedHttpException(
165                ( new MessageValue( 'rest-permission-denied-title' ) )->plaintextParams( $title ),
166                403
167            );
168        }
169
170        '@phan-var \MediaWiki\Page\ExistingPageRecord $page';
171        $redirectResponse = $this->getRedirectHelper()->createNormalizationRedirectResponseIfNeeded(
172            $page,
173            $params['title'] ?? null
174        );
175
176        if ( $redirectResponse !== null ) {
177            return $redirectResponse;
178        }
179
180        $count = $this->getCount( $normalizedType );
181        $countLimit = self::COUNT_LIMITS[$normalizedType];
182        $response = $this->getResponseFactory()->createJson( [
183                'count' => $count > $countLimit ? $countLimit : $count,
184                'limit' => $count > $countLimit
185        ] );
186        $response->setHeader( ResponseHeaders::CACHE_CONTROL, 'max-age=' . self::MAX_AGE_200 );
187
188        // Inform clients who use a deprecated "type" value, so they can adjust
189        if ( isset( self::DEPRECATED_COUNT_TYPES[$type] ) ) {
190            $docs = '<https://www.mediawiki.org/wiki/API:REST/History_API' .
191                '#Get_page_history_counts>; rel="deprecation"';
192            $response->setHeader( ResponseHeaders::DEPRECATION, 'version="v1"' );
193            $response->setHeader( ResponseHeaders::LINK, $docs );
194        }
195
196        return $response;
197    }
198
199    /**
200     * @param string $type the validated count type
201     * @return int the article count
202     * @throws LocalizedHttpException
203     */
204    private function getCount( $type ) {
205        $pageId = $this->getPage()->getId();
206        switch ( $type ) {
207            case 'anonymous':
208                return $this->getCachedCount( $type,
209                    function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) {
210                        return $this->getAnonCount( $pageId, $fromRev );
211                    }
212                );
213
214            case 'temporary':
215                return $this->getCachedCount( $type,
216                    function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) {
217                        return $this->getTempCount( $pageId, $fromRev );
218                    }
219                );
220
221            case 'bot':
222                return $this->getCachedCount( $type,
223                    function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) {
224                        return $this->getBotCount( $pageId, $fromRev );
225                    }
226                );
227
228            case 'editors':
229                $from = $this->getValidatedParams()['from'] ?? null;
230                $to = $this->getValidatedParams()['to'] ?? null;
231                if ( $from || $to ) {
232                    return $this->getEditorsCount(
233                        $pageId,
234                        $from ? $this->getRevisionOrThrow( $from ) : null,
235                        $to ? $this->getRevisionOrThrow( $to ) : null
236                    );
237                } else {
238                    return $this->getCachedCount( $type,
239                        function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) {
240                            return $this->getEditorsCount( $pageId, $fromRev );
241                        } );
242                }
243
244            case 'edits':
245                $from = $this->getValidatedParams()['from'] ?? null;
246                $to = $this->getValidatedParams()['to'] ?? null;
247                if ( $from || $to ) {
248                    return $this->getEditsCount(
249                        $pageId,
250                        $from ? $this->getRevisionOrThrow( $from ) : null,
251                        $to ? $this->getRevisionOrThrow( $to ) : null
252                    );
253                } else {
254                    return $this->getCachedCount( $type,
255                        function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) {
256                            return $this->getEditsCount( $pageId, $fromRev );
257                        }
258                    );
259                }
260
261            case 'reverted':
262                return $this->getCachedCount( $type,
263                    function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) {
264                        return $this->getRevertedCount( $pageId, $fromRev );
265                    }
266                );
267
268            case 'minor':
269                // The query for minor counts is inefficient for the database for pages with many revisions.
270                // If the specified title contains more revisions than allowed, we will return an error.
271                $editsCount = $this->getCachedCount( 'edits',
272                    function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) {
273                        return $this->getEditsCount( $pageId, $fromRev );
274                    }
275                );
276                if ( $editsCount > self::COUNT_LIMITS[$type] * 2 ) {
277                    throw new LocalizedHttpException(
278                        new MessageValue( 'rest-pagehistorycount-too-many-revisions' ),
279                        500
280                    );
281                }
282                return $this->getCachedCount( $type,
283                    function ( ?RevisionRecord $fromRev = null ) use ( $pageId ) {
284                        return $this->getMinorCount( $pageId, $fromRev );
285                    }
286                );
287
288            default:
289                throw new LocalizedHttpException(
290                    ( new MessageValue( 'rest-pagehistorycount-type-unrecognized' ) )
291                        ->plaintextParams( $type ),
292                    500
293                );
294        }
295    }
296
297    /**
298     * @return RevisionRecord|null current revision or false if unable to retrieve revision
299     */
300    private function getCurrentRevision(): ?RevisionRecord {
301        if ( $this->revision === false ) {
302            $page = $this->getPage();
303            if ( $page ) {
304                $this->revision = $this->revisionStore->getKnownCurrentRevision( $page ) ?: null;
305            } else {
306                $this->revision = null;
307            }
308        }
309        return $this->revision;
310    }
311
312    private function getPage(): ?ExistingPageRecord {
313        if ( $this->page === false ) {
314            $this->page = $this->pageLookup->getExistingPageByText(
315                $this->getValidatedParams()['title']
316            );
317        }
318        return $this->page;
319    }
320
321    /**
322     * Returns latest of 2 timestamps:
323     * 1. Current revision
324     * 2. OR entry from the DB logging table for the given page
325     * @return int|null
326     */
327    protected function getLastModified() {
328        $lastModifiedTimes = $this->getLastModifiedTimes();
329        if ( $lastModifiedTimes ) {
330            return max( array_values( $lastModifiedTimes ) );
331        }
332        return null;
333    }
334
335    /**
336     * Returns array with 2 timestamps:
337     * 1. Current revision
338     * 2. OR entry from the DB logging table for the given page
339     * @return array|null
340     */
341    protected function getLastModifiedTimes() {
342        $currentRev = $this->getCurrentRevision();
343        if ( !$currentRev ) {
344            return null;
345        }
346        if ( $this->lastModifiedTimes === null ) {
347            $currentRevTime = (int)wfTimestampOrNull( TS::UNIX, $currentRev->getTimestamp() );
348            $loggingTableTime = $this->loggingTableTime( $currentRev->getPageId() );
349            $this->lastModifiedTimes = [
350                'currentRevTS' => $currentRevTime,
351                'dependencyModTS' => $loggingTableTime
352            ];
353        }
354        return $this->lastModifiedTimes;
355    }
356
357    /**
358     * Return timestamp of latest entry in logging table for given page id
359     * @param int $pageId
360     * @return int|null
361     */
362    private function loggingTableTime( $pageId ) {
363        $res = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
364            ->select( 'MAX(log_timestamp)' )
365            ->from( 'logging' )
366            ->where( [ 'log_page' => $pageId ] )
367            ->caller( __METHOD__ )->fetchField();
368        return $res ? (int)wfTimestamp( TS::UNIX, $res ) : null;
369    }
370
371    /**
372     * Choosing to not implement etags in this handler.
373     * Generating an etag when getting revision counts must account for things like visibility settings
374     * (e.g. rev_deleted bit) which requires hitting the database anyway. The response for these
375     * requests are so small that we wouldn't be gaining much efficiency.
376     * Etags are strong validators and if provided would take precedence over
377     * last modified time, a weak validator. We want to ensure only last modified time is used
378     * since it is more efficient than using etags for this particular case.
379     * @return null
380     */
381    protected function getEtag() {
382        return null;
383    }
384
385    /**
386     * @param string $type
387     * @param callable $fetchCount
388     * @return int
389     */
390    private function getCachedCount( $type,
391        callable $fetchCount
392    ) {
393        $pageId = $this->getPage()->getId();
394        return $this->cache->getWithSetCallback(
395            $this->cache->makeKey( 'rest', 'pagehistorycount', $pageId, $type ),
396            WANObjectCache::TTL_WEEK,
397            function ( $oldValue ) use ( $fetchCount ) {
398                $currentRev = $this->getCurrentRevision();
399                if ( $oldValue ) {
400                    // Last modified timestamp was NOT a dependency change (e.g. revdel)
401                    $doIncrementalUpdate = (
402                        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
403                        $this->getLastModified() != $this->getLastModifiedTimes()['dependencyModTS']
404                    );
405                    if ( $doIncrementalUpdate ) {
406                        $rev = $this->revisionStore->getRevisionById( $oldValue['revision'] );
407                        if ( $rev ) {
408                            $additionalCount = $fetchCount( $rev );
409                            return [
410                                'revision' => $currentRev->getId(),
411                                'count' => $oldValue['count'] + $additionalCount,
412                                // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
413                                'dependencyModTS' => $this->getLastModifiedTimes()['dependencyModTS']
414                            ];
415                        }
416                    }
417                }
418                // Nothing was previously stored, or incremental update was done for too long,
419                // recalculate from scratch.
420                return [
421                    'revision' => $currentRev->getId(),
422                    'count' => $fetchCount(),
423                    // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
424                    'dependencyModTS' => $this->getLastModifiedTimes()['dependencyModTS']
425                ];
426            },
427            [
428                'touchedCallback' => $this->getLastModified( ... ),
429                'version' => 2,
430                'lockTSE' => WANObjectCache::TTL_MINUTE * 5
431            ]
432        )['count'];
433    }
434
435    /**
436     * @param int $pageId the id of the page to load history for
437     * @param RevisionRecord|null $fromRev
438     * @return int the count
439     */
440    protected function getAnonCount( $pageId, ?RevisionRecord $fromRev = null ) {
441        $dbr = $this->dbProvider->getReplicaDatabase();
442        $queryBuilder = $dbr->newSelectQueryBuilder()
443            ->select( '1' )
444            ->from( 'revision' )
445            ->join( 'actor', null, 'rev_actor = actor_id' )
446            ->where( [
447                'rev_page' => $pageId,
448                'actor_user' => null,
449                $dbr->bitAnd( 'rev_deleted',
450                    RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) => 0,
451            ] )
452            ->limit( self::COUNT_LIMITS['anonymous'] + 1 ); // extra to detect truncation
453
454        if ( $fromRev ) {
455            $queryBuilder->andWhere( $dbr->buildComparison( '>', [
456                'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
457                'rev_id' => $fromRev->getId(),
458            ] ) );
459        }
460
461        return $queryBuilder->caller( __METHOD__ )->fetchRowCount();
462    }
463
464    /**
465     * @param int $pageId the id of the page to load history for
466     * @param RevisionRecord|null $fromRev
467     * @return int the count
468     */
469    protected function getTempCount( $pageId, ?RevisionRecord $fromRev = null ) {
470        if ( !$this->tempUserConfig->isKnown() ) {
471            return 0;
472        }
473
474        $dbr = $this->dbProvider->getReplicaDatabase();
475        $queryBuilder = $dbr->newSelectQueryBuilder()
476            ->select( '1' )
477            ->from( 'revision' )
478            ->join( 'actor', null, 'rev_actor = actor_id' )
479            ->where( [
480                'rev_page' => $pageId,
481                $this->tempUserConfig->getMatchCondition(
482                    $dbr,
483                    'actor_name',
484                    IExpression::LIKE
485                ),
486            ] )
487            ->andWhere( [
488                $dbr->bitAnd(
489                    'rev_deleted',
490                    RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER
491                ) => 0
492            ] )
493            ->limit( self::COUNT_LIMITS['temporary'] + 1 ); // extra to detect truncation
494
495        if ( $fromRev ) {
496            $queryBuilder->andWhere( $dbr->buildComparison( '>', [
497                'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
498                'rev_id' => $fromRev->getId(),
499            ] ) );
500        }
501
502        return $queryBuilder->caller( __METHOD__ )->fetchRowCount();
503    }
504
505    /**
506     * @param int $pageId the id of the page to load history for
507     * @param RevisionRecord|null $fromRev
508     * @return int the count
509     */
510    protected function getBotCount( $pageId, ?RevisionRecord $fromRev = null ) {
511        $dbr = $this->dbProvider->getReplicaDatabase();
512
513        $queryBuilder = $dbr->newSelectQueryBuilder()
514            ->select( '1' )
515            ->from( 'revision' )
516            ->join( 'actor', 'actor_rev_user', 'actor_rev_user.actor_id = rev_actor' )
517            ->where( [ 'rev_page' => intval( $pageId ) ] )
518            ->andWhere( [
519                $dbr->bitAnd(
520                    'rev_deleted',
521                    RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER
522                ) => 0
523            ] )
524            ->limit( self::COUNT_LIMITS['bot'] + 1 ); // extra to detect truncation
525        $subquery = $queryBuilder->newSubquery()
526            ->select( '1' )
527            ->from( 'user_groups' )
528            ->where( [
529                'actor_rev_user.actor_user = ug_user',
530                'ug_group' => $this->groupPermissionsLookup->getGroupsWithPermission( 'bot' ),
531                $dbr->expr( 'ug_expiry', '=', null )->or( 'ug_expiry', '>=', $dbr->timestamp() )
532            ] );
533
534        $queryBuilder->andWhere( new RawSQLExpression( 'EXISTS(' . $subquery->getSQL() . ')' ) );
535        if ( $fromRev ) {
536            $queryBuilder->andWhere( $dbr->buildComparison( '>', [
537                'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
538                'rev_id' => $fromRev->getId(),
539            ] ) );
540        }
541
542        return $queryBuilder->caller( __METHOD__ )->fetchRowCount();
543    }
544
545    /**
546     * @param int $pageId the id of the page to load history for
547     * @param RevisionRecord|null $fromRev
548     * @param RevisionRecord|null $toRev
549     * @return int the count
550     */
551    protected function getEditorsCount( $pageId,
552        ?RevisionRecord $fromRev = null,
553        ?RevisionRecord $toRev = null
554    ) {
555        [ $fromRev, $toRev ] = $this->orderRevisions( $fromRev, $toRev );
556        return $this->revisionStore->countAuthorsBetween( $pageId, $fromRev,
557            $toRev, $this->getAuthority(), self::COUNT_LIMITS['editors'] );
558    }
559
560    /**
561     * @param int $pageId the id of the page to load history for
562     * @param RevisionRecord|null $fromRev
563     * @return int the count
564     */
565    protected function getRevertedCount( $pageId, ?RevisionRecord $fromRev = null ) {
566        $tagIds = [];
567
568        foreach ( ChangeTags::REVERT_TAGS as $tagName ) {
569            try {
570                $tagIds[] = $this->changeTagDefStore->getId( $tagName );
571            } catch ( NameTableAccessException ) {
572                // If no revisions are tagged with a name, no tag id will be present
573            }
574        }
575        if ( !$tagIds ) {
576            return 0;
577        }
578
579        $dbr = $this->dbProvider->getReplicaDatabase();
580        $queryBuilder = $dbr->newSelectQueryBuilder()
581            ->select( '1' )
582            ->from( 'revision' )
583            ->join( 'change_tag', null, 'ct_rev_id = rev_id' )
584            ->where( [
585                'rev_page' => $pageId,
586                'ct_tag_id' => $tagIds,
587                $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
588            ] )
589            ->groupBy( 'rev_id' )
590            ->limit( self::COUNT_LIMITS['reverted'] + 1 ); // extra to detect truncation
591
592        if ( $fromRev ) {
593            $queryBuilder->andWhere( $dbr->buildComparison( '>', [
594                'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
595                'rev_id' => $fromRev->getId(),
596            ] ) );
597        }
598
599        return $queryBuilder->caller( __METHOD__ )->fetchRowCount();
600    }
601
602    /**
603     * @param int $pageId the id of the page to load history for
604     * @param RevisionRecord|null $fromRev
605     * @return int the count
606     */
607    protected function getMinorCount( $pageId, ?RevisionRecord $fromRev = null ) {
608        $dbr = $this->dbProvider->getReplicaDatabase();
609        $queryBuilder = $dbr->newSelectQueryBuilder()
610            ->select( '1' )
611            ->from( 'revision' )
612            ->where( [
613                'rev_page' => $pageId,
614                $dbr->expr( 'rev_minor_edit', '!=', 0 ),
615                $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
616            ] )
617            ->limit( self::COUNT_LIMITS['minor'] + 1 ); // extra to detect truncation
618
619        if ( $fromRev ) {
620            $queryBuilder->andWhere( $dbr->buildComparison( '>', [
621                'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
622                'rev_id' => $fromRev->getId(),
623            ] ) );
624        }
625
626        return $queryBuilder->caller( __METHOD__ )->fetchRowCount();
627    }
628
629    /**
630     * @param int $pageId the id of the page to load history for
631     * @param RevisionRecord|null $fromRev
632     * @param RevisionRecord|null $toRev
633     * @return int the count
634     */
635    protected function getEditsCount(
636        $pageId,
637        ?RevisionRecord $fromRev = null,
638        ?RevisionRecord $toRev = null
639    ) {
640        [ $fromRev, $toRev ] = $this->orderRevisions( $fromRev, $toRev );
641        return $this->revisionStore->countRevisionsBetween(
642            $pageId,
643            $fromRev,
644            $toRev,
645            self::COUNT_LIMITS['edits'] // Will be increased by 1 to detect truncation
646        );
647    }
648
649    /**
650     * @param int $revId
651     * @return RevisionRecord
652     * @throws LocalizedHttpException
653     */
654    private function getRevisionOrThrow( $revId ) {
655        $rev = $this->revisionStore->getRevisionById( $revId );
656        if ( !$rev ) {
657            throw new LocalizedHttpException(
658                new MessageValue( 'rest-nonexistent-revision', [ $revId ] ),
659                404
660            );
661        }
662        return $rev;
663    }
664
665    /**
666     * Reorders revisions if they are present
667     * @param RevisionRecord|null $fromRev
668     * @param RevisionRecord|null $toRev
669     * @return array
670     * @phan-return array{0:RevisionRecord|null,1:RevisionRecord|null}
671     */
672    private function orderRevisions(
673        ?RevisionRecord $fromRev = null,
674        ?RevisionRecord $toRev = null
675    ) {
676        if ( $fromRev && $toRev && ( $fromRev->getTimestamp() > $toRev->getTimestamp() ||
677                ( $fromRev->getTimestamp() === $toRev->getTimestamp()
678                    && $fromRev->getId() > $toRev->getId() ) )
679        ) {
680            return [ $toRev, $fromRev ];
681        }
682        return [ $fromRev, $toRev ];
683    }
684
685    /** @inheritDoc */
686    public function needsWriteAccess() {
687        return false;
688    }
689
690    protected function getResponseBodySchemaFileName( string $method ): ?string {
691        return __DIR__ . '/Schema/PageHistoryCount.json';
692    }
693
694    /** @inheritDoc */
695    public function getParamSettings() {
696        return [
697            'title' => [
698                self::PARAM_SOURCE => 'path',
699                ParamValidator::PARAM_TYPE => 'string',
700                ParamValidator::PARAM_REQUIRED => true,
701                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-pagehistory-count-title' ),
702            ],
703            'type' => [
704                self::PARAM_SOURCE => 'path',
705                ParamValidator::PARAM_TYPE => array_merge(
706                    array_keys( self::COUNT_LIMITS ),
707                    array_keys( self::DEPRECATED_COUNT_TYPES )
708                ),
709                ParamValidator::PARAM_REQUIRED => true,
710                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-pagehistory-count-type' ),
711            ],
712            'from' => [
713                self::PARAM_SOURCE => 'query',
714                ParamValidator::PARAM_TYPE => 'integer',
715                ParamValidator::PARAM_REQUIRED => false,
716                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-pagehistory-count-from' ),
717            ],
718            'to' => [
719                self::PARAM_SOURCE => 'query',
720                ParamValidator::PARAM_TYPE => 'integer',
721                ParamValidator::PARAM_REQUIRED => false,
722                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-pagehistory-count-to' ),
723            ]
724        ];
725    }
726
727    /** @inheritDoc */
728    public function getResponseHeaderSettings(): array {
729        return array_merge(
730            parent::getResponseHeaderSettings(),
731            [
732                ResponseHeaders::LINK => ResponseHeaders::RESPONSE_HEADER_DEFINITIONS[
733                    ResponseHeaders::LINK
734                ]
735            ]
736        );
737    }
738}