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