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 MediaWiki\ChangeTags\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( 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 | } |