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