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