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 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\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( 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',
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    private function getPage(): ?ExistingPageRecord {
318        if ( $this->page === false ) {
319            $this->page = $this->pageLookup->getExistingPageByText(
320                $this->getValidatedParams()['title']
321            );
322        }
323        return $this->page;
324    }
325
326    /**
327     * Returns latest of 2 timestamps:
328     * 1. Current revision
329     * 2. OR entry from the DB logging table for the given page
330     * @return int|null
331     */
332    protected function getLastModified() {
333        $lastModifiedTimes = $this->getLastModifiedTimes();
334        if ( $lastModifiedTimes ) {
335            return max( array_values( $lastModifiedTimes ) );
336        }
337        return null;
338    }
339
340    /**
341     * Returns array with 2 timestamps:
342     * 1. Current revision
343     * 2. OR entry from the DB logging table for the given page
344     * @return array|null
345     */
346    protected function getLastModifiedTimes() {
347        $currentRev = $this->getCurrentRevision();
348        if ( !$currentRev ) {
349            return null;
350        }
351        if ( $this->lastModifiedTimes === null ) {
352            $currentRevTime = (int)wfTimestampOrNull( TS_UNIX, $currentRev->getTimestamp() );
353            $loggingTableTime = $this->loggingTableTime( $currentRev->getPageId() );
354            $this->lastModifiedTimes = [
355                'currentRevTS' => $currentRevTime,
356                'dependencyModTS' => $loggingTableTime
357            ];
358        }
359        return $this->lastModifiedTimes;
360    }
361
362    /**
363     * Return timestamp of latest entry in logging table for given page id
364     * @param int $pageId
365     * @return int|null
366     */
367    private function loggingTableTime( $pageId ) {
368        $res = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
369            ->select( 'MAX(log_timestamp)' )
370            ->from( 'logging' )
371            ->where( [ 'log_page' => $pageId ] )
372            ->caller( __METHOD__ )->fetchField();
373        return $res ? (int)wfTimestamp( TS_UNIX, $res ) : null;
374    }
375
376    /**
377     * Choosing to not implement etags in this handler.
378     * Generating an etag when getting revision counts must account for things like visibility settings
379     * (e.g. rev_deleted bit) which requires hitting the database anyway. The response for these
380     * requests are so small that we wouldn't be gaining much efficiency.
381     * Etags are strong validators and if provided would take precedence over
382     * last modified time, a weak validator. We want to ensure only last modified time is used
383     * since it is more efficient than using etags for this particular case.
384     * @return null
385     */
386    protected function getEtag() {
387        return null;
388    }
389
390    /**
391     * @param string $type
392     * @param callable $fetchCount
393     * @return int
394     */
395    private function getCachedCount( $type,
396        callable $fetchCount
397    ) {
398        $pageId = $this->getPage()->getId();
399        return $this->cache->getWithSetCallback(
400            $this->cache->makeKey( 'rest', 'pagehistorycount', $pageId, $type ),
401            WANObjectCache::TTL_WEEK,
402            function ( $oldValue ) use ( $fetchCount ) {
403                $currentRev = $this->getCurrentRevision();
404                if ( $oldValue ) {
405                    // Last modified timestamp was NOT a dependency change (e.g. revdel)
406                    $doIncrementalUpdate = (
407                        // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
408                        $this->getLastModified() != $this->getLastModifiedTimes()['dependencyModTS']
409                    );
410                    if ( $doIncrementalUpdate ) {
411                        $rev = $this->revisionStore->getRevisionById( $oldValue['revision'] );
412                        if ( $rev ) {
413                            $additionalCount = $fetchCount( $rev );
414                            return [
415                                'revision' => $currentRev->getId(),
416                                'count' => $oldValue['count'] + $additionalCount,
417                                // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
418                                'dependencyModTS' => $this->getLastModifiedTimes()['dependencyModTS']
419                            ];
420                        }
421                    }
422                }
423                // Nothing was previously stored, or incremental update was done for too long,
424                // recalculate from scratch.
425                return [
426                    'revision' => $currentRev->getId(),
427                    'count' => $fetchCount(),
428                    // @phan-suppress-next-line PhanTypeArraySuspiciousNullable
429                    'dependencyModTS' => $this->getLastModifiedTimes()['dependencyModTS']
430                ];
431            },
432            [
433                'touchedCallback' => function (){
434                    return $this->getLastModified();
435                },
436                'version' => 2,
437                'lockTSE' => WANObjectCache::TTL_MINUTE * 5
438            ]
439        )['count'];
440    }
441
442    /**
443     * @param int $pageId the id of the page to load history for
444     * @param RevisionRecord|null $fromRev
445     * @return int the count
446     */
447    protected function getAnonCount( $pageId, ?RevisionRecord $fromRev = null ) {
448        $dbr = $this->dbProvider->getReplicaDatabase();
449        $queryBuilder = $dbr->newSelectQueryBuilder()
450            ->select( '1' )
451            ->from( 'revision' )
452            ->join( 'actor', null, 'rev_actor = actor_id' )
453            ->where( [
454                'rev_page' => $pageId,
455                'actor_user' => null,
456                $dbr->bitAnd( 'rev_deleted',
457                    RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER ) => 0,
458            ] )
459            ->limit( self::COUNT_LIMITS['anonymous'] + 1 ); // extra to detect truncation
460
461        if ( $fromRev ) {
462            $queryBuilder->andWhere( $dbr->buildComparison( '>', [
463                'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
464                'rev_id' => $fromRev->getId(),
465            ] ) );
466        }
467
468        return $queryBuilder->caller( __METHOD__ )->fetchRowCount();
469    }
470
471    /**
472     * @param int $pageId the id of the page to load history for
473     * @param RevisionRecord|null $fromRev
474     * @return int the count
475     */
476    protected function getTempCount( $pageId, ?RevisionRecord $fromRev = null ) {
477        if ( !$this->tempUserConfig->isKnown() ) {
478            return 0;
479        }
480
481        $dbr = $this->dbProvider->getReplicaDatabase();
482        $queryBuilder = $dbr->newSelectQueryBuilder()
483            ->select( '1' )
484            ->from( 'revision' )
485            ->join( 'actor', null, 'rev_actor = actor_id' )
486            ->where( [
487                'rev_page' => $pageId,
488                $this->tempUserConfig->getMatchCondition(
489                    $dbr,
490                    'actor_name',
491                    IExpression::LIKE
492                ),
493            ] )
494            ->andWhere( [
495                $dbr->bitAnd(
496                    'rev_deleted',
497                    RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER
498                ) => 0
499            ] )
500            ->limit( self::COUNT_LIMITS['temporary'] + 1 ); // extra to detect truncation
501
502        if ( $fromRev ) {
503            $queryBuilder->andWhere( $dbr->buildComparison( '>', [
504                'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
505                'rev_id' => $fromRev->getId(),
506            ] ) );
507        }
508
509        return $queryBuilder->caller( __METHOD__ )->fetchRowCount();
510    }
511
512    /**
513     * @param int $pageId the id of the page to load history for
514     * @param RevisionRecord|null $fromRev
515     * @return int the count
516     */
517    protected function getBotCount( $pageId, ?RevisionRecord $fromRev = null ) {
518        $dbr = $this->dbProvider->getReplicaDatabase();
519
520        $queryBuilder = $dbr->newSelectQueryBuilder()
521            ->select( '1' )
522            ->from( 'revision' )
523            ->join( 'actor', 'actor_rev_user', 'actor_rev_user.actor_id = rev_actor' )
524            ->where( [ 'rev_page' => intval( $pageId ) ] )
525            ->andWhere( [
526                $dbr->bitAnd(
527                    'rev_deleted',
528                    RevisionRecord::DELETED_TEXT | RevisionRecord::DELETED_USER
529                ) => 0
530            ] )
531            ->limit( self::COUNT_LIMITS['bot'] + 1 ); // extra to detect truncation
532        $subquery = $queryBuilder->newSubquery()
533            ->select( '1' )
534            ->from( 'user_groups' )
535            ->where( [
536                'actor_rev_user.actor_user = ug_user',
537                'ug_group' => $this->groupPermissionsLookup->getGroupsWithPermission( 'bot' ),
538                $dbr->expr( 'ug_expiry', '=', null )->or( 'ug_expiry', '>=', $dbr->timestamp() )
539            ] );
540
541        $queryBuilder->andWhere( new RawSQLExpression( 'EXISTS(' . $subquery->getSQL() . ')' ) );
542        if ( $fromRev ) {
543            $queryBuilder->andWhere( $dbr->buildComparison( '>', [
544                'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
545                'rev_id' => $fromRev->getId(),
546            ] ) );
547        }
548
549        return $queryBuilder->caller( __METHOD__ )->fetchRowCount();
550    }
551
552    /**
553     * @param int $pageId the id of the page to load history for
554     * @param RevisionRecord|null $fromRev
555     * @param RevisionRecord|null $toRev
556     * @return int the count
557     */
558    protected function getEditorsCount( $pageId,
559        ?RevisionRecord $fromRev = null,
560        ?RevisionRecord $toRev = null
561    ) {
562        [ $fromRev, $toRev ] = $this->orderRevisions( $fromRev, $toRev );
563        return $this->revisionStore->countAuthorsBetween( $pageId, $fromRev,
564            $toRev, $this->getAuthority(), self::COUNT_LIMITS['editors'] );
565    }
566
567    /**
568     * @param int $pageId the id of the page to load history for
569     * @param RevisionRecord|null $fromRev
570     * @return int the count
571     */
572    protected function getRevertedCount( $pageId, ?RevisionRecord $fromRev = null ) {
573        $tagIds = [];
574
575        foreach ( ChangeTags::REVERT_TAGS as $tagName ) {
576            try {
577                $tagIds[] = $this->changeTagDefStore->getId( $tagName );
578            } catch ( NameTableAccessException $e ) {
579                // If no revisions are tagged with a name, no tag id will be present
580            }
581        }
582        if ( !$tagIds ) {
583            return 0;
584        }
585
586        $dbr = $this->dbProvider->getReplicaDatabase();
587        $queryBuilder = $dbr->newSelectQueryBuilder()
588            ->select( '1' )
589            ->from( 'revision' )
590            ->join( 'change_tag', null, 'ct_rev_id = rev_id' )
591            ->where( [
592                'rev_page' => $pageId,
593                'ct_tag_id' => $tagIds,
594                $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
595            ] )
596            ->groupBy( 'rev_id' )
597            ->limit( self::COUNT_LIMITS['reverted'] + 1 ); // extra to detect truncation
598
599        if ( $fromRev ) {
600            $queryBuilder->andWhere( $dbr->buildComparison( '>', [
601                'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
602                'rev_id' => $fromRev->getId(),
603            ] ) );
604        }
605
606        return $queryBuilder->caller( __METHOD__ )->fetchRowCount();
607    }
608
609    /**
610     * @param int $pageId the id of the page to load history for
611     * @param RevisionRecord|null $fromRev
612     * @return int the count
613     */
614    protected function getMinorCount( $pageId, ?RevisionRecord $fromRev = null ) {
615        $dbr = $this->dbProvider->getReplicaDatabase();
616        $queryBuilder = $dbr->newSelectQueryBuilder()
617            ->select( '1' )
618            ->from( 'revision' )
619            ->where( [
620                'rev_page' => $pageId,
621                $dbr->expr( 'rev_minor_edit', '!=', 0 ),
622                $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_TEXT ) . " = 0"
623            ] )
624            ->limit( self::COUNT_LIMITS['minor'] + 1 ); // extra to detect truncation
625
626        if ( $fromRev ) {
627            $queryBuilder->andWhere( $dbr->buildComparison( '>', [
628                'rev_timestamp' => $dbr->timestamp( $fromRev->getTimestamp() ),
629                'rev_id' => $fromRev->getId(),
630            ] ) );
631        }
632
633        return $queryBuilder->caller( __METHOD__ )->fetchRowCount();
634    }
635
636    /**
637     * @param int $pageId the id of the page to load history for
638     * @param RevisionRecord|null $fromRev
639     * @param RevisionRecord|null $toRev
640     * @return int the count
641     */
642    protected function getEditsCount(
643        $pageId,
644        ?RevisionRecord $fromRev = null,
645        ?RevisionRecord $toRev = null
646    ) {
647        [ $fromRev, $toRev ] = $this->orderRevisions( $fromRev, $toRev );
648        return $this->revisionStore->countRevisionsBetween(
649            $pageId,
650            $fromRev,
651            $toRev,
652            self::COUNT_LIMITS['edits'] // Will be increased by 1 to detect truncation
653        );
654    }
655
656    /**
657     * @param int $revId
658     * @return RevisionRecord
659     * @throws LocalizedHttpException
660     */
661    private function getRevisionOrThrow( $revId ) {
662        $rev = $this->revisionStore->getRevisionById( $revId );
663        if ( !$rev ) {
664            throw new LocalizedHttpException(
665                new MessageValue( 'rest-nonexistent-revision', [ $revId ] ),
666                404
667            );
668        }
669        return $rev;
670    }
671
672    /**
673     * Reorders revisions if they are present
674     * @param RevisionRecord|null $fromRev
675     * @param RevisionRecord|null $toRev
676     * @return array
677     * @phan-return array{0:RevisionRecord|null,1:RevisionRecord|null}
678     */
679    private function orderRevisions(
680        ?RevisionRecord $fromRev = null,
681        ?RevisionRecord $toRev = null
682    ) {
683        if ( $fromRev && $toRev && ( $fromRev->getTimestamp() > $toRev->getTimestamp() ||
684                ( $fromRev->getTimestamp() === $toRev->getTimestamp()
685                    && $fromRev->getId() > $toRev->getId() ) )
686        ) {
687            return [ $toRev, $fromRev ];
688        }
689        return [ $fromRev, $toRev ];
690    }
691
692    public function needsWriteAccess() {
693        return false;
694    }
695
696    protected function getResponseBodySchemaFileName( string $method ): ?string {
697        return 'includes/Rest/Handler/Schema/PageHistoryCount.json';
698    }
699
700    public function getParamSettings() {
701        return [
702            'title' => [
703                self::PARAM_SOURCE => 'path',
704                ParamValidator::PARAM_TYPE => 'string',
705                ParamValidator::PARAM_REQUIRED => true,
706                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-pagehistory-count-title' ),
707            ],
708            'type' => [
709                self::PARAM_SOURCE => 'path',
710                ParamValidator::PARAM_TYPE => array_merge(
711                    array_keys( self::COUNT_LIMITS ),
712                    array_keys( self::DEPRECATED_COUNT_TYPES )
713                ),
714                ParamValidator::PARAM_REQUIRED => true,
715                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-pagehistory-count-type' ),
716            ],
717            'from' => [
718                self::PARAM_SOURCE => 'query',
719                ParamValidator::PARAM_TYPE => 'integer',
720                ParamValidator::PARAM_REQUIRED => false,
721                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-pagehistory-count-from' ),
722            ],
723            'to' => [
724                self::PARAM_SOURCE => 'query',
725                ParamValidator::PARAM_TYPE => 'integer',
726                ParamValidator::PARAM_REQUIRED => false,
727                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-pagehistory-count-to' ),
728            ]
729        ];
730    }
731}