MediaWiki REL1_35
PageHistoryHandler.php
Go to the documentation of this file.
1<?php
2
4
16use Title;
17use User;
24
29 private const REVISIONS_RETURN_LIMIT = 20;
30 private const REVERTED_TAG_NAMES = [ 'mw-undo', 'mw-rollback' ];
31 private const ALLOWED_FILTER_TYPES = [ 'anonymous', 'bot', 'reverted', 'minor' ];
32
35
38
41
44
46 private $user;
47
51 private $title = null;
52
61 public function __construct(
63 NameTableStoreFactory $nameTableStoreFactory,
66 ) {
67 $this->revisionStore = $revisionStore;
68 $this->changeTagDefStore = $nameTableStoreFactory->getChangeTagDef();
69 $this->permissionManager = $permissionManager;
70 $this->loadBalancer = $loadBalancer;
71
72 // @todo Inject this, when there is a good way to do that
73 $this->user = RequestContext::getMain()->getUser();
74 }
75
79 private function getTitle() {
80 if ( $this->title === null ) {
81 $this->title = Title::newFromText( $this->getValidatedParams()['title'] ) ?? false;
82 }
83 return $this->title;
84 }
85
95 public function run( $title ) {
96 $params = $this->getValidatedParams();
97 if ( $params['older_than'] !== null && $params['newer_than'] !== null ) {
98 throw new LocalizedHttpException(
99 new MessageValue( 'rest-pagehistory-incompatible-params' ), 400 );
100 }
101
102 if ( ( $params['older_than'] !== null && $params['older_than'] < 1 ) ||
103 ( $params['newer_than'] !== null && $params['newer_than'] < 1 )
104 ) {
105 throw new LocalizedHttpException(
106 new MessageValue( 'rest-pagehistory-param-range-error' ), 400 );
107 }
108
109 $tagIds = [];
110 if ( $params['filter'] === 'reverted' ) {
111 foreach ( self::REVERTED_TAG_NAMES as $tagName ) {
112 try {
113 $tagIds[] = $this->changeTagDefStore->getId( $tagName );
114 } catch ( NameTableAccessException $exception ) {
115 // If no revisions are tagged with a name, no tag id will be present
116 }
117 }
118 }
119
120 $titleObj = Title::newFromText( $title );
121 if ( !$titleObj || !$titleObj->getArticleID() ) {
122 throw new LocalizedHttpException(
123 new MessageValue( 'rest-nonexistent-title',
124 [ new ScalarParam( ParamType::PLAINTEXT, $title ) ]
125 ),
126 404
127 );
128 }
129 if ( !$this->permissionManager->userCan( 'read', $this->user, $titleObj ) ) {
130 throw new LocalizedHttpException(
131 new MessageValue( 'rest-permission-denied-title',
132 [ new ScalarParam( ParamType::PLAINTEXT, $title ) ] ),
133 403
134 );
135 }
136
137 $relativeRevId = $params['older_than'] ?? $params['newer_than'] ?? 0;
138 if ( $relativeRevId ) {
139 // Confirm the relative revision exists for this page. If so, get its timestamp.
140 $rev = $this->revisionStore->getRevisionByPageId(
141 $titleObj->getArticleID(),
142 $relativeRevId
143 );
144 if ( !$rev ) {
145 throw new LocalizedHttpException(
146 new MessageValue( 'rest-nonexistent-title-revision',
147 [ $relativeRevId, new ScalarParam( ParamType::PLAINTEXT, $title ) ]
148 ),
149 404
150 );
151 }
152 $ts = $rev->getTimestamp();
153 if ( $ts === null ) {
154 throw new LocalizedHttpException(
155 new MessageValue( 'rest-pagehistory-timestamp-error',
156 [ $relativeRevId ]
157 ),
158 500
159 );
160 }
161 } else {
162 $ts = 0;
163 }
164
165 $res = $this->getDbResults( $titleObj, $params, $relativeRevId, $ts, $tagIds );
166 $response = $this->processDbResults( $res, $titleObj, $params );
167 return $this->getResponseFactory()->createJson( $response );
168 }
169
178 private function getDbResults( Title $titleObj, array $params, $relativeRevId, $ts, $tagIds ) {
179 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
180 $revQuery = $this->revisionStore->getQueryInfo();
181 $cond = [
182 'rev_page' => $titleObj->getArticleID()
183 ];
184
185 if ( $params['filter'] ) {
186 // This redundant join condition tells MySQL that rev_page and revactor_page are the
187 // same, so it can propagate the condition
188 $revQuery['joins']['temp_rev_user'][1] =
189 "temp_rev_user.revactor_rev = rev_id AND revactor_page = rev_page";
190
191 // The validator ensures this value, if present, is one of the expected values
192 switch ( $params['filter'] ) {
193 case 'bot':
194 $cond[] = 'EXISTS(' . $dbr->selectSQLText(
195 'user_groups',
196 '1',
197 [
198 'actor_rev_user.actor_user = ug_user',
199 'ug_group' => $this->permissionManager->getGroupsWithPermission( 'bot' ),
200 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() )
201 ],
202 __METHOD__
203 ) . ')';
204 $bitmask = $this->getBitmask();
205 if ( $bitmask ) {
206 $cond[] = $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask";
207 }
208 break;
209
210 case 'anonymous':
211 $cond[] = "actor_user IS NULL";
212 $bitmask = $this->getBitmask();
213 if ( $bitmask ) {
214 $cond[] = $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask";
215 }
216 break;
217
218 case 'reverted':
219 if ( !$tagIds ) {
220 return false;
221 }
222 $cond[] = 'EXISTS(' . $dbr->selectSQLText(
223 'change_tag',
224 '1',
225 [ 'ct_rev_id = rev_id', 'ct_tag_id' => $tagIds ],
226 __METHOD__
227 ) . ')';
228 break;
229
230 case 'minor':
231 $cond[] = 'rev_minor_edit != 0';
232 break;
233 }
234 }
235
236 if ( $relativeRevId ) {
237 $op = $params['older_than'] ? '<' : '>';
238 $sort = $params['older_than'] ? 'DESC' : 'ASC';
239 $ts = $dbr->addQuotes( $dbr->timestamp( $ts ) );
240 $cond[] = "rev_timestamp $op $ts OR " .
241 "(rev_timestamp = $ts AND rev_id $op $relativeRevId)";
242 $orderBy = "rev_timestamp $sort, rev_id $sort";
243 } else {
244 $orderBy = "rev_timestamp DESC, rev_id DESC";
245 }
246
247 // Select one more than the return limit, to learn if there are additional revisions.
248 $limit = self::REVISIONS_RETURN_LIMIT + 1;
249
250 $res = $dbr->select(
251 $revQuery['tables'],
252 $revQuery['fields'],
253 $cond,
254 __METHOD__,
255 [
256 'ORDER BY' => $orderBy,
257 'LIMIT' => $limit,
258 ],
259 $revQuery['joins']
260 );
261
262 return $res;
263 }
264
272 private function getBitmask() {
273 if ( !$this->permissionManager->userHasRight( $this->user, 'deletedhistory' ) ) {
274 $bitmask = RevisionRecord::DELETED_USER;
275 } elseif ( !$this->permissionManager
276 ->userHasAnyRight( $this->user, 'suppressrevision', 'viewsuppressed' )
277 ) {
278 $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED;
279 } else {
280 $bitmask = 0;
281 }
282 return $bitmask;
283 }
284
291 private function processDbResults( $res, $titleObj, $params ) {
292 $revisions = [];
293
294 if ( $res ) {
295 $sizes = [];
296 foreach ( $res as $row ) {
297 $rev = $this->revisionStore->newRevisionFromRow(
298 $row,
299 IDBAccessObject::READ_NORMAL,
300 $titleObj
301 );
302 if ( !$revisions ) {
303 $firstRevId = $row->rev_id;
304 }
305 $lastRevId = $row->rev_id;
306
307 $revision = [
308 'id' => $rev->getId(),
309 'timestamp' => wfTimestamp( TS_ISO_8601, $rev->getTimestamp() ),
310 'minor' => $rev->isMinor(),
311 'size' => $rev->getSize()
312 ];
313
314 // Remember revision sizes and parent ids for calculating deltas. If a revision's
315 // parent id is unknown, we will be unable to supply the delta for that revision.
316 $sizes[$rev->getId()] = $rev->getSize();
317 $parentId = $rev->getParentId();
318 if ( $parentId ) {
319 $revision['parent_id'] = $parentId;
320 }
321
322 $comment = $rev->getComment( RevisionRecord::FOR_THIS_USER, $this->user );
323 $revision['comment'] = $comment ? $comment->text : null;
324
325 $revUser = $rev->getUser( RevisionRecord::FOR_THIS_USER, $this->user );
326 if ( $revUser ) {
327 $revision['user'] = [
328 'id' => $revUser->isRegistered() ? $revUser->getId() : null,
329 'name' => $revUser->getName()
330 ];
331 } else {
332 $revision['user'] = null;
333 }
334
335 $revisions[] = $revision;
336
337 // Break manually at the return limit. We may have more results than we can return.
338 if ( count( $revisions ) == self::REVISIONS_RETURN_LIMIT ) {
339 break;
340 }
341 }
342
343 // Request any parent sizes that we do not already know, then calculate deltas
344 $unknownSizes = [];
345 foreach ( $revisions as $revision ) {
346 if ( isset( $revision['parent_id'] ) && !isset( $sizes[$revision['parent_id']] ) ) {
347 $unknownSizes[] = $revision['parent_id'];
348 }
349 }
350 if ( $unknownSizes ) {
351 $sizes += $this->revisionStore->getRevisionSizes( $unknownSizes );
352 }
353 foreach ( $revisions as &$revision ) {
354 $revision['delta'] = null;
355 if ( isset( $revision['parent_id'] ) ) {
356 if ( isset( $sizes[$revision['parent_id']] ) ) {
357 $revision['delta'] = $revision['size'] - $sizes[$revision['parent_id']];
358 }
359
360 // We only remembered this for delta calculations. We do not want to return it.
361 unset( $revision['parent_id'] );
362 }
363 }
364
365 if ( $revisions && $params['newer_than'] ) {
366 $revisions = array_reverse( $revisions );
367 $temp = $lastRevId;
368 $lastRevId = $firstRevId;
369 $firstRevId = $temp;
370 }
371 }
372
373 $response = [
374 'revisions' => $revisions
375 ];
376
377 // Omit newer/older if there are no additional corresponding revisions.
378 // This facilitates clients doing "paging" style api operations.
379 if ( $revisions ) {
380 if ( $params['newer_than'] || $res->numRows() > self::REVISIONS_RETURN_LIMIT ) {
381 $older = $lastRevId;
382 }
383 if ( $params['older_than'] ||
384 ( $params['newer_than'] && $res->numRows() > self::REVISIONS_RETURN_LIMIT )
385 ) {
386 $newer = $firstRevId;
387 }
388 }
389
390 $queryParts = [];
391
392 if ( isset( $params['filter'] ) ) {
393 $queryParts['filter'] = $params['filter'];
394 }
395
396 $pathParams = [ 'title' => $titleObj->getPrefixedDBkey() ];
397
398 $response['latest'] = $this->getRouteUrl( $pathParams, $queryParts );
399
400 if ( isset( $older ) ) {
401 $response['older'] =
402 $this->getRouteUrl( $pathParams, $queryParts + [ 'older_than' => $older ] );
403 }
404 if ( isset( $newer ) ) {
405 $response['newer'] =
406 $this->getRouteUrl( $pathParams, $queryParts + [ 'newer_than' => $newer ] );
407 }
408
409 return $response;
410 }
411
412 public function needsWriteAccess() {
413 return false;
414 }
415
416 public function getParamSettings() {
417 return [
418 'title' => [
419 self::PARAM_SOURCE => 'path',
420 ParamValidator::PARAM_TYPE => 'string',
421 ParamValidator::PARAM_REQUIRED => true,
422 ],
423 'older_than' => [
424 self::PARAM_SOURCE => 'query',
425 ParamValidator::PARAM_TYPE => 'integer',
426 ParamValidator::PARAM_REQUIRED => false,
427 ],
428 'newer_than' => [
429 self::PARAM_SOURCE => 'query',
430 ParamValidator::PARAM_TYPE => 'integer',
431 ParamValidator::PARAM_REQUIRED => false,
432 ],
433 'filter' => [
434 self::PARAM_SOURCE => 'query',
435 ParamValidator::PARAM_TYPE => self::ALLOWED_FILTER_TYPES,
436 ParamValidator::PARAM_REQUIRED => false,
437 ],
438 ];
439 }
440
446 protected function getETag(): ?string {
447 $title = $this->getTitle();
448 if ( !$title || !$title->getArticleID() ) {
449 return null;
450 }
451
452 return '"' . $title->getLatestRevID() . '"';
453 }
454
460 protected function getLastModified(): ?string {
461 $title = $this->getTitle();
462 if ( !$title || !$title->getArticleID() ) {
463 return null;
464 }
465
466 $rev = $this->revisionStore->getKnownCurrentRevision( $title );
467 return $rev->getTimestamp();
468 }
469
473 protected function hasRepresentation() {
474 $title = $this->getTitle();
475 return $title ? $title->exists() : false;
476 }
477}
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
Handler class for Core REST API endpoints that perform operations on revisions.
getETag()
Returns an ETag representing a page's latest revision.
__construct(RevisionStore $revisionStore, NameTableStoreFactory $nameTableStoreFactory, PermissionManager $permissionManager, ILoadBalancer $loadBalancer)
RevisionStore $revisionStore.
getLastModified()
Returns the time of the last change to the page.
getBitmask()
Helper function for rev_deleted/user rights query conditions.
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.
getDbResults(Title $titleObj, array $params, $relativeRevId, $ts, $tagIds)
getRouteUrl( $pathParams=[], $queryParams=[])
Get the URL of this handler's endpoint.
Definition Handler.php:92
getValidatedParams()
Fetch the validated parameters.
Definition Handler.php:257
getResponseFactory()
Get the ResponseFactory which can be used to generate Response objects.
Definition Handler.php:151
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.
Group all the pieces relevant to the context of a request into one instance @newable.
Represents a title within MediaWiki.
Definition Title.php:42
getLatestRevID( $flags=0)
What is the page_latest field for this page?
Definition Title.php:3311
getArticleID( $flags=0)
Get the article ID for this Title from the link cache, adding it if necessary.
Definition Title.php:3225
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
Definition User.php:60
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.
Database cluster connection, tracking, load balancing, and transaction manager interface.
Result wrapper for grabbing data queried from an IDatabase object.
const DB_REPLICA
Definition defines.php:25