MediaWiki REL1_39
PageHistoryHandler.php
Go to the documentation of this file.
1<?php
2
4
5use ChangeTags;
25
30 private const REVISIONS_RETURN_LIMIT = 20;
31 private const ALLOWED_FILTER_TYPES = [ 'anonymous', 'bot', 'reverted', 'minor' ];
32
34 private $revisionStore;
35
37 private $changeTagDefStore;
38
40 private $groupPermissionsLookup;
41
43 private $loadBalancer;
44
46 private $pageLookup;
47
49 private $titleFormatter;
50
54 private $page = false;
55
66 public function __construct(
67 RevisionStore $revisionStore,
68 NameTableStoreFactory $nameTableStoreFactory,
69 GroupPermissionsLookup $groupPermissionsLookup,
70 ILoadBalancer $loadBalancer,
71 PageLookup $pageLookup,
72 TitleFormatter $titleFormatter
73 ) {
74 $this->revisionStore = $revisionStore;
75 $this->changeTagDefStore = $nameTableStoreFactory->getChangeTagDef();
76 $this->groupPermissionsLookup = $groupPermissionsLookup;
77 $this->loadBalancer = $loadBalancer;
78 $this->pageLookup = $pageLookup;
79 $this->titleFormatter = $titleFormatter;
80 }
81
85 private function getPage(): ?ExistingPageRecord {
86 if ( $this->page === false ) {
87 $this->page = $this->pageLookup->getExistingPageByText(
88 $this->getValidatedParams()['title']
89 );
90 }
91 return $this->page;
92 }
93
103 public function run( $title ) {
104 $params = $this->getValidatedParams();
105 if ( $params['older_than'] !== null && $params['newer_than'] !== null ) {
106 throw new LocalizedHttpException(
107 new MessageValue( 'rest-pagehistory-incompatible-params' ), 400 );
108 }
109
110 if ( ( $params['older_than'] !== null && $params['older_than'] < 1 ) ||
111 ( $params['newer_than'] !== null && $params['newer_than'] < 1 )
112 ) {
113 throw new LocalizedHttpException(
114 new MessageValue( 'rest-pagehistory-param-range-error' ), 400 );
115 }
116
117 $tagIds = [];
118 if ( $params['filter'] === 'reverted' ) {
119 foreach ( ChangeTags::REVERT_TAGS as $tagName ) {
120 try {
121 $tagIds[] = $this->changeTagDefStore->getId( $tagName );
122 } catch ( NameTableAccessException $exception ) {
123 // If no revisions are tagged with a name, no tag id will be present
124 }
125 }
126 }
127
128 $page = $this->getPage();
129 if ( !$page ) {
130 throw new LocalizedHttpException(
131 new MessageValue( 'rest-nonexistent-title',
132 [ new ScalarParam( ParamType::PLAINTEXT, $title ) ]
133 ),
134 404
135 );
136 }
137 if ( !$this->getAuthority()->authorizeRead( 'read', $page ) ) {
138 throw new LocalizedHttpException(
139 new MessageValue( 'rest-permission-denied-title',
140 [ new ScalarParam( ParamType::PLAINTEXT, $title ) ] ),
141 403
142 );
143 }
144
145 $relativeRevId = $params['older_than'] ?? $params['newer_than'] ?? 0;
146 if ( $relativeRevId ) {
147 // Confirm the relative revision exists for this page. If so, get its timestamp.
148 $rev = $this->revisionStore->getRevisionByPageId(
149 $page->getId(),
150 $relativeRevId
151 );
152 if ( !$rev ) {
153 throw new LocalizedHttpException(
154 new MessageValue( 'rest-nonexistent-title-revision',
155 [ $relativeRevId, new ScalarParam( ParamType::PLAINTEXT, $title ) ]
156 ),
157 404
158 );
159 }
160 $ts = $rev->getTimestamp();
161 if ( $ts === null ) {
162 throw new LocalizedHttpException(
163 new MessageValue( 'rest-pagehistory-timestamp-error',
164 [ $relativeRevId ]
165 ),
166 500
167 );
168 }
169 } else {
170 $ts = 0;
171 }
172
173 $res = $this->getDbResults( $page, $params, $relativeRevId, $ts, $tagIds );
174 $response = $this->processDbResults( $res, $page, $params );
175 return $this->getResponseFactory()->createJson( $response );
176 }
177
186 private function getDbResults( ExistingPageRecord $page, array $params, $relativeRevId, $ts, $tagIds ) {
187 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
188 $revQuery = $this->revisionStore->getQueryInfo();
189 $cond = [
190 'rev_page' => $page->getId()
191 ];
192
193 if ( $params['filter'] ) {
194 // This redundant join condition tells MySQL that rev_page and revactor_page are the
195 // same, so it can propagate the condition
196 if ( isset( $revQuery['tables']['temp_rev_user'] ) /* SCHEMA_COMPAT_READ_TEMP */ ) {
197 $revQuery['joins']['temp_rev_user'][1] =
198 "temp_rev_user.revactor_rev = rev_id AND revactor_page = rev_page";
199 }
200
201 // The validator ensures this value, if present, is one of the expected values
202 switch ( $params['filter'] ) {
203 case 'bot':
204 $cond[] = 'EXISTS(' . $dbr->selectSQLText(
205 'user_groups',
206 '1',
207 [
208 'actor_rev_user.actor_user = ug_user',
209 'ug_group' => $this->groupPermissionsLookup->getGroupsWithPermission( 'bot' ),
210 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() )
211 ],
212 __METHOD__
213 ) . ')';
214 $bitmask = $this->getBitmask();
215 if ( $bitmask ) {
216 $cond[] = $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask";
217 }
218 break;
219
220 case 'anonymous':
221 $cond[] = "actor_user IS NULL";
222 $bitmask = $this->getBitmask();
223 if ( $bitmask ) {
224 $cond[] = $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask";
225 }
226 break;
227
228 case 'reverted':
229 if ( !$tagIds ) {
230 return false;
231 }
232 $cond[] = 'EXISTS(' . $dbr->selectSQLText(
233 'change_tag',
234 '1',
235 [ 'ct_rev_id = rev_id', 'ct_tag_id' => $tagIds ],
236 __METHOD__
237 ) . ')';
238 break;
239
240 case 'minor':
241 $cond[] = 'rev_minor_edit != 0';
242 break;
243 }
244 }
245
246 if ( $relativeRevId ) {
247 $op = $params['older_than'] ? '<' : '>';
248 $sort = $params['older_than'] ? 'DESC' : 'ASC';
249 $ts = $dbr->addQuotes( $dbr->timestamp( $ts ) );
250 $cond[] = "rev_timestamp $op $ts OR " .
251 "(rev_timestamp = $ts AND rev_id $op $relativeRevId)";
252 $orderBy = "rev_timestamp $sort, rev_id $sort";
253 } else {
254 $orderBy = "rev_timestamp DESC, rev_id DESC";
255 }
256
257 // Select one more than the return limit, to learn if there are additional revisions.
258 $limit = self::REVISIONS_RETURN_LIMIT + 1;
259
260 $res = $dbr->select(
261 // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
262 $revQuery['tables'],
263 $revQuery['fields'],
264 $cond,
265 __METHOD__,
266 [
267 'ORDER BY' => $orderBy,
268 'LIMIT' => $limit,
269 ],
270 $revQuery['joins']
271 );
272
273 return $res;
274 }
275
283 private function getBitmask() {
284 if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
285 $bitmask = RevisionRecord::DELETED_USER;
286 } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
287 $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED;
288 } else {
289 $bitmask = 0;
290 }
291 return $bitmask;
292 }
293
300 private function processDbResults( $res, $page, $params ) {
301 $revisions = [];
302
303 if ( $res ) {
304 $sizes = [];
305 foreach ( $res as $row ) {
306 $rev = $this->revisionStore->newRevisionFromRow(
307 $row,
308 IDBAccessObject::READ_NORMAL,
309 $page
310 );
311 if ( !$revisions ) {
312 $firstRevId = $row->rev_id;
313 }
314 $lastRevId = $row->rev_id;
315
316 $revision = [
317 'id' => $rev->getId(),
318 'timestamp' => wfTimestamp( TS_ISO_8601, $rev->getTimestamp() ),
319 'minor' => $rev->isMinor(),
320 'size' => $rev->getSize()
321 ];
322
323 // Remember revision sizes and parent ids for calculating deltas. If a revision's
324 // parent id is unknown, we will be unable to supply the delta for that revision.
325 $sizes[$rev->getId()] = $rev->getSize();
326 $parentId = $rev->getParentId();
327 if ( $parentId ) {
328 $revision['parent_id'] = $parentId;
329 }
330
331 $comment = $rev->getComment( RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
332 $revision['comment'] = $comment ? $comment->text : null;
333
334 $revUser = $rev->getUser( RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
335 if ( $revUser ) {
336 $revision['user'] = [
337 'id' => $revUser->isRegistered() ? $revUser->getId() : null,
338 'name' => $revUser->getName()
339 ];
340 } else {
341 $revision['user'] = null;
342 }
343
344 $revisions[] = $revision;
345
346 // Break manually at the return limit. We may have more results than we can return.
347 if ( count( $revisions ) == self::REVISIONS_RETURN_LIMIT ) {
348 break;
349 }
350 }
351
352 // Request any parent sizes that we do not already know, then calculate deltas
353 $unknownSizes = [];
354 foreach ( $revisions as $revision ) {
355 if ( isset( $revision['parent_id'] ) && !isset( $sizes[$revision['parent_id']] ) ) {
356 $unknownSizes[] = $revision['parent_id'];
357 }
358 }
359 if ( $unknownSizes ) {
360 $sizes += $this->revisionStore->getRevisionSizes( $unknownSizes );
361 }
362 foreach ( $revisions as &$revision ) {
363 $revision['delta'] = null;
364 if ( isset( $revision['parent_id'] ) ) {
365 if ( isset( $sizes[$revision['parent_id']] ) ) {
366 $revision['delta'] = $revision['size'] - $sizes[$revision['parent_id']];
367 }
368
369 // We only remembered this for delta calculations. We do not want to return it.
370 unset( $revision['parent_id'] );
371 }
372 }
373
374 if ( $revisions && $params['newer_than'] ) {
375 $revisions = array_reverse( $revisions );
376 // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
377 // $lastRevId is declared because $res has one element
378 $temp = $lastRevId;
379 // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
380 // $firstRevId is declared because $res has one element
381 $lastRevId = $firstRevId;
382 $firstRevId = $temp;
383 }
384 }
385
386 $response = [
387 'revisions' => $revisions
388 ];
389
390 // Omit newer/older if there are no additional corresponding revisions.
391 // This facilitates clients doing "paging" style api operations.
392 if ( $revisions ) {
393 if ( $params['newer_than'] || $res->numRows() > self::REVISIONS_RETURN_LIMIT ) {
394 // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
395 // $lastRevId is declared because $res has one element
396 $older = $lastRevId;
397 }
398 if ( $params['older_than'] ||
399 ( $params['newer_than'] && $res->numRows() > self::REVISIONS_RETURN_LIMIT )
400 ) {
401 // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
402 // $firstRevId is declared because $res has one element
403 $newer = $firstRevId;
404 }
405 }
406
407 $queryParts = [];
408
409 if ( isset( $params['filter'] ) ) {
410 $queryParts['filter'] = $params['filter'];
411 }
412
413 $pathParams = [ 'title' => $this->titleFormatter->getPrefixedDBkey( $page ) ];
414
415 $response['latest'] = $this->getRouteUrl( $pathParams, $queryParts );
416
417 if ( isset( $older ) ) {
418 $response['older'] =
419 $this->getRouteUrl( $pathParams, $queryParts + [ 'older_than' => $older ] );
420 }
421 if ( isset( $newer ) ) {
422 $response['newer'] =
423 $this->getRouteUrl( $pathParams, $queryParts + [ 'newer_than' => $newer ] );
424 }
425
426 return $response;
427 }
428
429 public function needsWriteAccess() {
430 return false;
431 }
432
433 public function getParamSettings() {
434 return [
435 'title' => [
436 self::PARAM_SOURCE => 'path',
437 ParamValidator::PARAM_TYPE => 'string',
438 ParamValidator::PARAM_REQUIRED => true,
439 ],
440 'older_than' => [
441 self::PARAM_SOURCE => 'query',
442 ParamValidator::PARAM_TYPE => 'integer',
443 ParamValidator::PARAM_REQUIRED => false,
444 ],
445 'newer_than' => [
446 self::PARAM_SOURCE => 'query',
447 ParamValidator::PARAM_TYPE => 'integer',
448 ParamValidator::PARAM_REQUIRED => false,
449 ],
450 'filter' => [
451 self::PARAM_SOURCE => 'query',
452 ParamValidator::PARAM_TYPE => self::ALLOWED_FILTER_TYPES,
453 ParamValidator::PARAM_REQUIRED => false,
454 ],
455 ];
456 }
457
463 protected function getETag(): ?string {
464 $page = $this->getPage();
465 if ( !$page ) {
466 return null;
467 }
468
469 return '"' . $page->getLatest() . '"';
470 }
471
477 protected function getLastModified(): ?string {
478 $page = $this->getPage();
479 if ( !$page ) {
480 return null;
481 }
482
483 $rev = $this->revisionStore->getKnownCurrentRevision( $page );
484 return $rev->getTimestamp();
485 }
486
490 protected function hasRepresentation() {
491 return (bool)$this->getPage();
492 }
493}
getAuthority()
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
if(!defined('MW_SETUP_CALLBACK'))
The persistent session ID (if any) loaded at startup.
Definition WebStart.php:82
const REVERT_TAGS
List of tags which denote a revert of some sort.
Handler class for Core REST API endpoints that perform operations on revisions.
getETag()
Returns an ETag representing a page's latest revision.
getLastModified()
Returns the time of the last change to the page.
needsWriteAccess()
Indicates whether this route requires write access.
run( $title)
At most one of older_than and newer_than may be specified.
getParamSettings()
Fetch ParamValidator settings for parameters.
__construct(RevisionStore $revisionStore, NameTableStoreFactory $nameTableStoreFactory, GroupPermissionsLookup $groupPermissionsLookup, ILoadBalancer $loadBalancer, PageLookup $pageLookup, TitleFormatter $titleFormatter)
RevisionStore $revisionStore.
getRouteUrl( $pathParams=[], $queryParams=[])
Get the URL of this handler's endpoint.
Definition Handler.php:110
getValidatedParams()
Fetch the validated parameters.
Definition Handler.php:336
getResponseFactory()
Get the ResponseFactory which can be used to generate Response objects.
Definition Handler.php:179
Page revision base class.
Service for looking up page revisions.
Exception representing a failure to look up a row from a name table.
getChangeTagDef( $wiki=false)
Get a NameTableStore for the change_tag_def table.
Value object representing a message for i18n.
The constants used to specify parameter types.
Definition ParamType.php:11
Value object representing a message parameter holding a single value.
Service for formatting and validating API parameters.
Interface for database access objects.
Data record representing a page that currently exists as an editable page on a wiki.
Service for looking up information about wiki pages.
getLatest( $wikiId=self::LOCAL)
The ID of the page's latest revision.
getId( $wikiId=self::LOCAL)
Returns the page ID.
A title formatter service for MediaWiki.
Create and track the database connections and transactions for a given database cluster.
Result wrapper for grabbing data queried from an IDatabase object.
Copyright (C) 2011-2020 Wikimedia Foundation and others.
const DB_REPLICA
Definition defines.php:26