Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 242 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
PageHistoryHandler | |
0.00% |
0 / 242 |
|
0.00% |
0 / 13 |
4970 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getRedirectHelper | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
getPage | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
run | |
0.00% |
0 / 58 |
|
0.00% |
0 / 1 |
272 | |||
getDbResults | |
0.00% |
0 / 50 |
|
0.00% |
0 / 1 |
156 | |||
getBitmask | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
processDbResults | |
0.00% |
0 / 72 |
|
0.00% |
0 / 1 |
756 | |||
needsWriteAccess | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getParamSettings | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
2 | |||
getETag | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getLastModified | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
hasRepresentation | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getResponseBodySchemaFileName | |
0.00% |
0 / 1 |
|
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\Title\TitleFormatter; |
21 | use Wikimedia\Message\MessageValue; |
22 | use Wikimedia\Message\ParamType; |
23 | use Wikimedia\Message\ScalarParam; |
24 | use Wikimedia\ParamValidator\ParamValidator; |
25 | use Wikimedia\Rdbms\IConnectionProvider; |
26 | use Wikimedia\Rdbms\IDBAccessObject; |
27 | use Wikimedia\Rdbms\IResultWrapper; |
28 | use Wikimedia\Rdbms\RawSQLExpression; |
29 | |
30 | /** |
31 | * Handler class for Core REST API endpoints that perform operations on revisions |
32 | */ |
33 | class PageHistoryHandler extends SimpleHandler { |
34 | |
35 | private const REVISIONS_RETURN_LIMIT = 20; |
36 | private const ALLOWED_FILTER_TYPES = [ 'anonymous', 'bot', 'reverted', 'minor' ]; |
37 | |
38 | private RevisionStore $revisionStore; |
39 | private NameTableStore $changeTagDefStore; |
40 | private GroupPermissionsLookup $groupPermissionsLookup; |
41 | private IConnectionProvider $dbProvider; |
42 | private PageLookup $pageLookup; |
43 | private TitleFormatter $titleFormatter; |
44 | private PageRestHelperFactory $helperFactory; |
45 | |
46 | /** |
47 | * @var ExistingPageRecord|false|null |
48 | */ |
49 | private $page = false; |
50 | |
51 | /** |
52 | * RevisionStore $revisionStore |
53 | * |
54 | * @param RevisionStore $revisionStore |
55 | * @param NameTableStoreFactory $nameTableStoreFactory |
56 | * @param GroupPermissionsLookup $groupPermissionsLookup |
57 | * @param IConnectionProvider $dbProvider |
58 | * @param PageLookup $pageLookup |
59 | * @param TitleFormatter $titleFormatter |
60 | * @param PageRestHelperFactory $helperFactory |
61 | */ |
62 | public function __construct( |
63 | RevisionStore $revisionStore, |
64 | NameTableStoreFactory $nameTableStoreFactory, |
65 | GroupPermissionsLookup $groupPermissionsLookup, |
66 | IConnectionProvider $dbProvider, |
67 | PageLookup $pageLookup, |
68 | TitleFormatter $titleFormatter, |
69 | PageRestHelperFactory $helperFactory |
70 | ) { |
71 | $this->revisionStore = $revisionStore; |
72 | $this->changeTagDefStore = $nameTableStoreFactory->getChangeTagDef(); |
73 | $this->groupPermissionsLookup = $groupPermissionsLookup; |
74 | $this->dbProvider = $dbProvider; |
75 | $this->pageLookup = $pageLookup; |
76 | $this->titleFormatter = $titleFormatter; |
77 | $this->helperFactory = $helperFactory; |
78 | } |
79 | |
80 | private function getRedirectHelper(): PageRedirectHelper { |
81 | return $this->helperFactory->newPageRedirectHelper( |
82 | $this->getResponseFactory(), |
83 | $this->getRouter(), |
84 | $this->getPath(), |
85 | $this->getRequest() |
86 | ); |
87 | } |
88 | |
89 | /** |
90 | * @return ExistingPageRecord|null |
91 | */ |
92 | private function getPage(): ?ExistingPageRecord { |
93 | if ( $this->page === false ) { |
94 | $this->page = $this->pageLookup->getExistingPageByText( |
95 | $this->getValidatedParams()['title'] |
96 | ); |
97 | } |
98 | return $this->page; |
99 | } |
100 | |
101 | /** |
102 | * At most one of older_than and newer_than may be specified. Keep in mind that revision ids |
103 | * are not monotonically increasing, so a revision may be older than another but have a |
104 | * higher revision id. |
105 | * |
106 | * @param string $title |
107 | * @return Response |
108 | * @throws LocalizedHttpException |
109 | */ |
110 | public function run( $title ) { |
111 | $params = $this->getValidatedParams(); |
112 | if ( $params['older_than'] !== null && $params['newer_than'] !== null ) { |
113 | throw new LocalizedHttpException( |
114 | new MessageValue( 'rest-pagehistory-incompatible-params' ), 400 ); |
115 | } |
116 | |
117 | if ( ( $params['older_than'] !== null && $params['older_than'] < 1 ) || |
118 | ( $params['newer_than'] !== null && $params['newer_than'] < 1 ) |
119 | ) { |
120 | throw new LocalizedHttpException( |
121 | new MessageValue( 'rest-pagehistory-param-range-error' ), 400 ); |
122 | } |
123 | |
124 | $tagIds = []; |
125 | if ( $params['filter'] === 'reverted' ) { |
126 | foreach ( ChangeTags::REVERT_TAGS as $tagName ) { |
127 | try { |
128 | $tagIds[] = $this->changeTagDefStore->getId( $tagName ); |
129 | } catch ( NameTableAccessException $exception ) { |
130 | // If no revisions are tagged with a name, no tag id will be present |
131 | } |
132 | } |
133 | } |
134 | |
135 | $page = $this->getPage(); |
136 | |
137 | if ( !$page ) { |
138 | throw new LocalizedHttpException( |
139 | new MessageValue( 'rest-nonexistent-title', |
140 | [ new ScalarParam( ParamType::PLAINTEXT, $title ) ] |
141 | ), |
142 | 404 |
143 | ); |
144 | } |
145 | if ( !$this->getAuthority()->authorizeRead( 'read', $page ) ) { |
146 | throw new LocalizedHttpException( |
147 | new MessageValue( 'rest-permission-denied-title', |
148 | [ new ScalarParam( ParamType::PLAINTEXT, $title ) ] ), |
149 | 403 |
150 | ); |
151 | } |
152 | |
153 | '@phan-var \MediaWiki\Page\ExistingPageRecord $page'; |
154 | $redirectResponse = $this->getRedirectHelper()->createNormalizationRedirectResponseIfNeeded( |
155 | $page, |
156 | $params['title'] ?? null |
157 | ); |
158 | |
159 | if ( $redirectResponse !== null ) { |
160 | return $redirectResponse; |
161 | } |
162 | |
163 | $relativeRevId = $params['older_than'] ?? $params['newer_than'] ?? 0; |
164 | if ( $relativeRevId ) { |
165 | // Confirm the relative revision exists for this page. If so, get its timestamp. |
166 | $rev = $this->revisionStore->getRevisionByPageId( |
167 | $page->getId(), |
168 | $relativeRevId |
169 | ); |
170 | if ( !$rev ) { |
171 | throw new LocalizedHttpException( |
172 | new MessageValue( 'rest-nonexistent-title-revision', |
173 | [ $relativeRevId, new ScalarParam( ParamType::PLAINTEXT, $title ) ] |
174 | ), |
175 | 404 |
176 | ); |
177 | } |
178 | $ts = $rev->getTimestamp(); |
179 | if ( $ts === null ) { |
180 | throw new LocalizedHttpException( |
181 | new MessageValue( 'rest-pagehistory-timestamp-error', |
182 | [ $relativeRevId ] |
183 | ), |
184 | 500 |
185 | ); |
186 | } |
187 | } else { |
188 | $ts = 0; |
189 | } |
190 | |
191 | $res = $this->getDbResults( $page, $params, $relativeRevId, $ts, $tagIds ); |
192 | $response = $this->processDbResults( $res, $page, $params ); |
193 | return $this->getResponseFactory()->createJson( $response ); |
194 | } |
195 | |
196 | /** |
197 | * @param ExistingPageRecord $page object identifying the page to load history for |
198 | * @param array $params request parameters |
199 | * @param int $relativeRevId relative revision id for paging, or zero if none |
200 | * @param int $ts timestamp for paging, or zero if none |
201 | * @param array $tagIds validated tags ids, or empty array if not needed for this query |
202 | * @return IResultWrapper|bool the results, or false if no query was executed |
203 | */ |
204 | private function getDbResults( ExistingPageRecord $page, array $params, $relativeRevId, $ts, $tagIds ) { |
205 | $dbr = $this->dbProvider->getReplicaDatabase(); |
206 | $queryBuilder = $this->revisionStore->newSelectQueryBuilder( $dbr ) |
207 | ->joinComment() |
208 | ->where( [ 'rev_page' => $page->getId() ] ) |
209 | // Select one more than the return limit, to learn if there are additional revisions. |
210 | ->limit( self::REVISIONS_RETURN_LIMIT + 1 ); |
211 | |
212 | if ( $params['filter'] ) { |
213 | // The validator ensures this value, if present, is one of the expected values |
214 | switch ( $params['filter'] ) { |
215 | case 'bot': |
216 | $subquery = $queryBuilder->newSubquery() |
217 | ->select( '1' ) |
218 | ->from( 'user_groups' ) |
219 | ->where( [ |
220 | 'actor_rev_user.actor_user = ug_user', |
221 | 'ug_group' => $this->groupPermissionsLookup->getGroupsWithPermission( 'bot' ), |
222 | $dbr->expr( 'ug_expiry', '=', null )->or( 'ug_expiry', '>=', $dbr->timestamp() ) |
223 | ] ); |
224 | $queryBuilder->andWhere( new RawSQLExpression( 'EXISTS(' . $subquery->getSQL() . ')' ) ); |
225 | $bitmask = $this->getBitmask(); |
226 | if ( $bitmask ) { |
227 | $queryBuilder->andWhere( $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask" ); |
228 | } |
229 | break; |
230 | |
231 | case 'anonymous': |
232 | $queryBuilder->andWhere( [ 'actor_user' => null ] ); |
233 | $bitmask = $this->getBitmask(); |
234 | if ( $bitmask ) { |
235 | $queryBuilder->andWhere( $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask" ); |
236 | } |
237 | break; |
238 | |
239 | case 'reverted': |
240 | if ( !$tagIds ) { |
241 | return false; |
242 | } |
243 | $subquery = $queryBuilder->newSubquery() |
244 | ->select( '1' ) |
245 | ->from( 'change_tag' ) |
246 | ->where( [ 'ct_rev_id = rev_id', 'ct_tag_id' => $tagIds ] ); |
247 | $queryBuilder->andWhere( new RawSQLExpression( 'EXISTS(' . $subquery->getSQL() . ')' ) ); |
248 | break; |
249 | |
250 | case 'minor': |
251 | $queryBuilder->andWhere( $dbr->expr( 'rev_minor_edit', '!=', 0 ) ); |
252 | break; |
253 | } |
254 | } |
255 | |
256 | if ( $relativeRevId ) { |
257 | $op = $params['older_than'] ? '<' : '>'; |
258 | $sort = $params['older_than'] ? 'DESC' : 'ASC'; |
259 | $queryBuilder->andWhere( $dbr->buildComparison( $op, [ |
260 | 'rev_timestamp' => $dbr->timestamp( $ts ), |
261 | 'rev_id' => $relativeRevId, |
262 | ] ) ); |
263 | $queryBuilder->orderBy( [ 'rev_timestamp', 'rev_id' ], $sort ); |
264 | } else { |
265 | $queryBuilder->orderBy( [ 'rev_timestamp', 'rev_id' ], 'DESC' ); |
266 | } |
267 | |
268 | return $queryBuilder->caller( __METHOD__ )->fetchResultSet(); |
269 | } |
270 | |
271 | /** |
272 | * Helper function for rev_deleted/user rights query conditions |
273 | * |
274 | * @todo Factor out rev_deleted logic per T233222 |
275 | * |
276 | * @return int |
277 | */ |
278 | private function getBitmask() { |
279 | if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) { |
280 | $bitmask = RevisionRecord::DELETED_USER; |
281 | } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { |
282 | $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED; |
283 | } else { |
284 | $bitmask = 0; |
285 | } |
286 | return $bitmask; |
287 | } |
288 | |
289 | /** |
290 | * @param IResultWrapper|bool $res database results, or false if no query was executed |
291 | * @param ExistingPageRecord $page object identifying the page to load history for |
292 | * @param array $params request parameters |
293 | * @return array response data |
294 | */ |
295 | private function processDbResults( $res, $page, $params ) { |
296 | $revisions = []; |
297 | |
298 | if ( $res ) { |
299 | $sizes = []; |
300 | foreach ( $res as $row ) { |
301 | $rev = $this->revisionStore->newRevisionFromRow( |
302 | $row, |
303 | IDBAccessObject::READ_NORMAL, |
304 | $page |
305 | ); |
306 | if ( !$revisions ) { |
307 | $firstRevId = $row->rev_id; |
308 | } |
309 | $lastRevId = $row->rev_id; |
310 | |
311 | $revision = [ |
312 | 'id' => $rev->getId(), |
313 | 'timestamp' => wfTimestamp( TS_ISO_8601, $rev->getTimestamp() ), |
314 | 'minor' => $rev->isMinor(), |
315 | 'size' => $rev->getSize() |
316 | ]; |
317 | |
318 | // Remember revision sizes and parent ids for calculating deltas. If a revision's |
319 | // parent id is unknown, we will be unable to supply the delta for that revision. |
320 | $sizes[$rev->getId()] = $rev->getSize(); |
321 | $parentId = $rev->getParentId(); |
322 | if ( $parentId ) { |
323 | $revision['parent_id'] = $parentId; |
324 | } |
325 | |
326 | $comment = $rev->getComment( RevisionRecord::FOR_THIS_USER, $this->getAuthority() ); |
327 | $revision['comment'] = $comment ? $comment->text : null; |
328 | |
329 | $revUser = $rev->getUser( RevisionRecord::FOR_THIS_USER, $this->getAuthority() ); |
330 | if ( $revUser ) { |
331 | $revision['user'] = [ |
332 | 'id' => $revUser->isRegistered() ? $revUser->getId() : null, |
333 | 'name' => $revUser->getName() |
334 | ]; |
335 | } else { |
336 | $revision['user'] = null; |
337 | } |
338 | |
339 | $revisions[] = $revision; |
340 | |
341 | // Break manually at the return limit. We may have more results than we can return. |
342 | if ( count( $revisions ) == self::REVISIONS_RETURN_LIMIT ) { |
343 | break; |
344 | } |
345 | } |
346 | |
347 | // Request any parent sizes that we do not already know, then calculate deltas |
348 | $unknownSizes = []; |
349 | foreach ( $revisions as $revision ) { |
350 | if ( isset( $revision['parent_id'] ) && !isset( $sizes[$revision['parent_id']] ) ) { |
351 | $unknownSizes[] = $revision['parent_id']; |
352 | } |
353 | } |
354 | if ( $unknownSizes ) { |
355 | $sizes += $this->revisionStore->getRevisionSizes( $unknownSizes ); |
356 | } |
357 | foreach ( $revisions as &$revision ) { |
358 | $revision['delta'] = null; |
359 | if ( isset( $revision['parent_id'] ) ) { |
360 | if ( isset( $sizes[$revision['parent_id']] ) ) { |
361 | $revision['delta'] = $revision['size'] - $sizes[$revision['parent_id']]; |
362 | } |
363 | |
364 | // We only remembered this for delta calculations. We do not want to return it. |
365 | unset( $revision['parent_id'] ); |
366 | } |
367 | } |
368 | |
369 | if ( $revisions && $params['newer_than'] ) { |
370 | $revisions = array_reverse( $revisions ); |
371 | // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable |
372 | // $lastRevId is declared because $res has one element |
373 | $temp = $lastRevId; |
374 | // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable |
375 | // $firstRevId is declared because $res has one element |
376 | $lastRevId = $firstRevId; |
377 | $firstRevId = $temp; |
378 | } |
379 | } |
380 | |
381 | $response = [ |
382 | 'revisions' => $revisions |
383 | ]; |
384 | |
385 | // Omit newer/older if there are no additional corresponding revisions. |
386 | // This facilitates clients doing "paging" style api operations. |
387 | if ( $revisions ) { |
388 | if ( $params['newer_than'] || $res->numRows() > self::REVISIONS_RETURN_LIMIT ) { |
389 | // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable |
390 | // $lastRevId is declared because $res has one element |
391 | $older = $lastRevId; |
392 | } |
393 | if ( $params['older_than'] || |
394 | ( $params['newer_than'] && $res->numRows() > self::REVISIONS_RETURN_LIMIT ) |
395 | ) { |
396 | // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable |
397 | // $firstRevId is declared because $res has one element |
398 | $newer = $firstRevId; |
399 | } |
400 | } |
401 | |
402 | $queryParts = []; |
403 | |
404 | if ( isset( $params['filter'] ) ) { |
405 | $queryParts['filter'] = $params['filter']; |
406 | } |
407 | |
408 | $pathParams = [ 'title' => $this->titleFormatter->getPrefixedDBkey( $page ) ]; |
409 | |
410 | $response['latest'] = $this->getRouteUrl( $pathParams, $queryParts ); |
411 | |
412 | if ( isset( $older ) ) { |
413 | $response['older'] = |
414 | $this->getRouteUrl( $pathParams, $queryParts + [ 'older_than' => $older ] ); |
415 | } |
416 | if ( isset( $newer ) ) { |
417 | $response['newer'] = |
418 | $this->getRouteUrl( $pathParams, $queryParts + [ 'newer_than' => $newer ] ); |
419 | } |
420 | |
421 | return $response; |
422 | } |
423 | |
424 | public function needsWriteAccess() { |
425 | return false; |
426 | } |
427 | |
428 | public function getParamSettings() { |
429 | return [ |
430 | 'title' => [ |
431 | self::PARAM_SOURCE => 'path', |
432 | ParamValidator::PARAM_TYPE => 'string', |
433 | ParamValidator::PARAM_REQUIRED => true, |
434 | Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-pagehistory-title' ), |
435 | ], |
436 | 'older_than' => [ |
437 | self::PARAM_SOURCE => 'query', |
438 | ParamValidator::PARAM_TYPE => 'integer', |
439 | ParamValidator::PARAM_REQUIRED => false, |
440 | Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-pagehistory-older-than' ), |
441 | ], |
442 | 'newer_than' => [ |
443 | self::PARAM_SOURCE => 'query', |
444 | ParamValidator::PARAM_TYPE => 'integer', |
445 | ParamValidator::PARAM_REQUIRED => false, |
446 | Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-pagehistory-newer-than' ), |
447 | ], |
448 | 'filter' => [ |
449 | self::PARAM_SOURCE => 'query', |
450 | ParamValidator::PARAM_TYPE => self::ALLOWED_FILTER_TYPES, |
451 | ParamValidator::PARAM_REQUIRED => false, |
452 | Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-pagehistory-filter' ), |
453 | ], |
454 | ]; |
455 | } |
456 | |
457 | /** |
458 | * Returns an ETag representing a page's latest revision. |
459 | * |
460 | * @return string|null |
461 | */ |
462 | protected function getETag(): ?string { |
463 | $page = $this->getPage(); |
464 | if ( !$page ) { |
465 | return null; |
466 | } |
467 | |
468 | return '"' . $page->getLatest() . '"'; |
469 | } |
470 | |
471 | /** |
472 | * Returns the time of the last change to the page. |
473 | * |
474 | * @return string|null |
475 | */ |
476 | protected function getLastModified(): ?string { |
477 | $page = $this->getPage(); |
478 | if ( !$page ) { |
479 | return null; |
480 | } |
481 | |
482 | $rev = $this->revisionStore->getKnownCurrentRevision( $page ); |
483 | return $rev->getTimestamp(); |
484 | } |
485 | |
486 | /** |
487 | * @return bool |
488 | */ |
489 | protected function hasRepresentation() { |
490 | return (bool)$this->getPage(); |
491 | } |
492 | |
493 | public function getResponseBodySchemaFileName( string $method ): ?string { |
494 | return 'includes/Rest/Handler/Schema/PageHistory.json'; |
495 | } |
496 | } |