Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 373 |
|
0.00% |
0 / 25 |
CRAP | |
0.00% |
0 / 1 |
PageHistoryCountHandler | |
0.00% |
0 / 373 |
|
0.00% |
0 / 25 |
6480 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
getRedirectHelper | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
normalizeType | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
validateParameterCombination | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
72 | |||
run | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
42 | |||
getCount | |
0.00% |
0 / 74 |
|
0.00% |
0 / 1 |
342 | |||
getCurrentRevision | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
getPage | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
getLastModified | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getLastModifiedTimes | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
loggingTableTime | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
getEtag | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getCachedCount | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
20 | |||
getAnonCount | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
6 | |||
getTempCount | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
12 | |||
getBotCount | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
6 | |||
getEditorsCount | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getRevertedCount | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
30 | |||
getMinorCount | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 | |||
getEditsCount | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getRevisionOrThrow | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
orderRevisions | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
42 | |||
needsWriteAccess | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getResponseBodySchemaFileName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getParamSettings | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Rest\Handler; |
4 | |
5 | use ChangeTags; |
6 | use MediaWiki\Page\ExistingPageRecord; |
7 | use MediaWiki\Page\PageLookup; |
8 | use MediaWiki\Permissions\GroupPermissionsLookup; |
9 | use MediaWiki\Rest\Handler; |
10 | use MediaWiki\Rest\Handler\Helper\PageRedirectHelper; |
11 | use MediaWiki\Rest\Handler\Helper\PageRestHelperFactory; |
12 | use MediaWiki\Rest\LocalizedHttpException; |
13 | use MediaWiki\Rest\Response; |
14 | use MediaWiki\Rest\SimpleHandler; |
15 | use MediaWiki\Revision\RevisionRecord; |
16 | use MediaWiki\Revision\RevisionStore; |
17 | use MediaWiki\Storage\NameTableAccessException; |
18 | use MediaWiki\Storage\NameTableStore; |
19 | use MediaWiki\Storage\NameTableStoreFactory; |
20 | use MediaWiki\User\TempUser\TempUserConfig; |
21 | use Wikimedia\Message\MessageValue; |
22 | use Wikimedia\Message\ParamType; |
23 | use Wikimedia\Message\ScalarParam; |
24 | use Wikimedia\ObjectCache\WANObjectCache; |
25 | use Wikimedia\ParamValidator\ParamValidator; |
26 | use Wikimedia\Rdbms\IConnectionProvider; |
27 | use Wikimedia\Rdbms\IExpression; |
28 | use Wikimedia\Rdbms\RawSQLExpression; |
29 | |
30 | /** |
31 | * Handler class for Core REST API endpoints that perform operations on revisions |
32 | */ |
33 | class 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 | } |