Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 368 |
|
0.00% |
0 / 24 |
CRAP | |
0.00% |
0 / 1 |
PageHistoryCountHandler | |
0.00% |
0 / 368 |
|
0.00% |
0 / 24 |
6320 | |
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 | |||
getParamSettings | |
0.00% |
0 / 25 |
|
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\Helper\PageRedirectHelper; |
10 | use MediaWiki\Rest\Handler\Helper\PageRestHelperFactory; |
11 | use MediaWiki\Rest\LocalizedHttpException; |
12 | use MediaWiki\Rest\Response; |
13 | use MediaWiki\Rest\SimpleHandler; |
14 | use MediaWiki\Revision\RevisionRecord; |
15 | use MediaWiki\Revision\RevisionStore; |
16 | use MediaWiki\Storage\NameTableAccessException; |
17 | use MediaWiki\Storage\NameTableStore; |
18 | use MediaWiki\Storage\NameTableStoreFactory; |
19 | use MediaWiki\User\TempUser\TempUserConfig; |
20 | use Wikimedia\Message\MessageValue; |
21 | use Wikimedia\Message\ParamType; |
22 | use Wikimedia\Message\ScalarParam; |
23 | use Wikimedia\ObjectCache\WANObjectCache; |
24 | use Wikimedia\ParamValidator\ParamValidator; |
25 | use Wikimedia\Rdbms\IConnectionProvider; |
26 | use Wikimedia\Rdbms\IExpression; |
27 | use Wikimedia\Rdbms\RawSQLExpression; |
28 | |
29 | /** |
30 | * Handler class for Core REST API endpoints that perform operations on revisions |
31 | */ |
32 | class 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 | $dbr->expr( '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 | } |