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