MediaWiki  master
PageHistoryHandler.php
Go to the documentation of this file.
1 <?php
2 
3 namespace MediaWiki\Rest\Handler;
4 
13 use RequestContext;
14 use User;
24 use Title;
25 
31  const REVERTED_TAG_NAMES = [ 'mw-undo', 'mw-rollback' ];
32  const ALLOWED_FILTER_TYPES = [ 'anonymous', 'bot', 'reverted', 'minor' ];
33 
35  private $revisionStore;
36 
39 
42 
44  private $loadBalancer;
45 
47  private $user;
48 
55  public function __construct(
57  NameTableStoreFactory $nameTableStoreFactory,
60  ) {
61  $this->revisionStore = $revisionStore;
62  $this->changeTagDefStore = $nameTableStoreFactory->getChangeTagDef();
63  $this->permissionManager = $permissionManager;
64  $this->loadBalancer = $loadBalancer;
65 
66  // @todo Inject this, when there is a good way to do that
67  $this->user = RequestContext::getMain()->getUser();
68  }
69 
80  public function run( $title ) {
81  $params = $this->getValidatedParams();
82  if ( $params['older_than'] !== null && $params['newer_than'] !== null ) {
83  throw new LocalizedHttpException(
84  new MessageValue( 'rest-pagehistory-incompatible-params' ), 400 );
85  }
86 
87  if ( ( $params['older_than'] !== null && $params['older_than'] < 1 ) ||
88  ( $params['newer_than'] !== null && $params['newer_than'] < 1 )
89  ) {
90  throw new LocalizedHttpException(
91  new MessageValue( 'rest-pagehistory-param-range-error' ), 400 );
92  }
93 
94  $tagIds = [];
95  if ( $params['filter'] === 'reverted' ) {
96  foreach ( self::REVERTED_TAG_NAMES as $tagName ) {
97  try {
98  $tagIds[] = $this->changeTagDefStore->getId( $tagName );
99  } catch ( NameTableAccessException $exception ) {
100  // If no revisions are tagged with a name, no tag id will be present
101  }
102  }
103  }
104 
105  $titleObj = Title::newFromText( $title );
106  if ( !$titleObj || !$titleObj->getArticleID() ) {
107  throw new LocalizedHttpException(
108  new MessageValue( 'rest-nonexistent-title',
109  [ new ScalarParam( ParamType::PLAINTEXT, $title ) ]
110  ),
111  404
112  );
113  }
114  if ( !$this->permissionManager->userCan( 'read', $this->user, $titleObj ) ) {
115  throw new LocalizedHttpException(
116  new MessageValue( 'rest-permission-denied-title',
117  [ new ScalarParam( ParamType::PLAINTEXT, $title ) ] ),
118  403
119  );
120  }
121 
122  $relativeRevId = $params['older_than'] ?? $params['newer_than'] ?? 0;
123  if ( $relativeRevId ) {
124  // Confirm the relative revision exists for this page. If so, get its timestamp.
125  $rev = $this->revisionStore->getRevisionByPageId(
126  $titleObj->getArticleID(),
127  $relativeRevId
128  );
129  if ( !$rev ) {
130  throw new LocalizedHttpException(
131  new MessageValue( 'rest-nonexistent-title-revision',
132  [ $relativeRevId, new ScalarParam( ParamType::PLAINTEXT, $title ) ]
133  ),
134  404
135  );
136  }
137  $ts = $rev->getTimestamp();
138  if ( $ts === null ) {
139  throw new LocalizedHttpException(
140  new MessageValue( 'rest-pagehistory-timestamp-error',
141  [ $relativeRevId ]
142  ),
143  500
144  );
145  }
146  } else {
147  $ts = 0;
148  }
149 
150  $res = $this->getDbResults( $titleObj, $params, $relativeRevId, $ts, $tagIds );
151  $response = $this->processDbResults( $res, $titleObj, $params );
152  return $this->getResponseFactory()->createJson( $response );
153  }
154 
163  private function getDbResults( Title $titleObj, array $params, $relativeRevId, $ts, $tagIds ) {
164  $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA );
165  $revQuery = $this->revisionStore->getQueryInfo();
166  $cond = [
167  'rev_page' => $titleObj->getArticleID()
168  ];
169 
170  if ( $params['filter'] ) {
171  // This redundant join condition tells MySQL that rev_page and revactor_page are the
172  // same, so it can propagate the condition
173  $revQuery['joins']['temp_rev_user'][1] =
174  "temp_rev_user.revactor_rev = rev_id AND revactor_page = rev_page";
175 
176  // The validator ensures this value, if present, is one of the expected values
177  switch ( $params['filter'] ) {
178  case 'bot':
179  $cond[] = 'EXISTS(' . $dbr->selectSQLText(
180  'user_groups',
181  1,
182  [
183  'actor_rev_user.actor_user = ug_user',
184  'ug_group' => $this->permissionManager->getGroupsWithPermission( 'bot' ),
185  'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() )
186  ],
187  __METHOD__
188  ) . ')';
189  $bitmask = $this->getBitmask();
190  if ( $bitmask ) {
191  $cond[] = $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask";
192  }
193  break;
194 
195  case 'anonymous':
196  $cond[] = "actor_user IS NULL";
197  $bitmask = $this->getBitmask();
198  if ( $bitmask ) {
199  $cond[] = $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask";
200  }
201  break;
202 
203  case 'reverted':
204  if ( !$tagIds ) {
205  return false;
206  }
207  $cond[] = 'EXISTS(' . $dbr->selectSQLText(
208  'change_tag',
209  1,
210  [ 'ct_rev_id = rev_id', 'ct_tag_id' => $tagIds ],
211  __METHOD__
212  ) . ')';
213  break;
214 
215  case 'minor':
216  $cond[] = 'rev_minor_edit != 0';
217  break;
218  }
219  }
220 
221  if ( $relativeRevId ) {
222  $op = $params['older_than'] ? '<' : '>';
223  $sort = $params['older_than'] ? 'DESC' : 'ASC';
224  $ts = $dbr->addQuotes( $dbr->timestamp( $ts ) );
225  $cond[] = "rev_timestamp $op $ts OR " .
226  "(rev_timestamp = $ts AND rev_id $op $relativeRevId)";
227  $orderBy = "rev_timestamp $sort, rev_id $sort";
228  } else {
229  $orderBy = "rev_timestamp DESC, rev_id DESC";
230  }
231 
232  // Select one more than the return limit, to learn if there are additional revisions.
233  $limit = self::REVISIONS_RETURN_LIMIT + 1;
234 
235  $res = $dbr->select(
236  $revQuery['tables'],
237  $revQuery['fields'],
238  $cond,
239  __METHOD__,
240  [
241  'ORDER BY' => $orderBy,
242  'LIMIT' => $limit,
243  ],
244  $revQuery['joins']
245  );
246 
247  return $res;
248  }
249 
257  private function getBitmask() {
258  if ( !$this->permissionManager->userHasRight( $this->user, 'deletedhistory' ) ) {
259  $bitmask = RevisionRecord::DELETED_USER;
260  } elseif ( !$this->permissionManager
261  ->userHasAnyRight( $this->user, 'suppressrevision', 'viewsuppressed' )
262  ) {
263  $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED;
264  } else {
265  $bitmask = 0;
266  }
267  return $bitmask;
268  }
269 
276  private function processDbResults( $res, $titleObj, $params ) {
277  $revisions = [];
278 
279  if ( $res ) {
280  $sizes = [];
281  foreach ( $res as $row ) {
282  $rev = $this->revisionStore->newRevisionFromRow(
283  $row,
284  IDBAccessObject::READ_NORMAL,
285  $titleObj
286  );
287  if ( !$revisions ) {
288  $firstRevId = $row->rev_id;
289  }
290  $lastRevId = $row->rev_id;
291 
292  $revision = [
293  'id' => $rev->getId(),
294  'timestamp' => wfTimestamp( TS_ISO_8601, $rev->getTimestamp() ),
295  'minor' => $rev->isMinor(),
296  'size' => $rev->getSize()
297  ];
298 
299  // Remember revision sizes and parent ids for calculating deltas. If a revision's
300  // parent id is unknown, we will be unable to supply the delta for that revision.
301  $sizes[$rev->getId()] = $rev->getSize();
302  $parentId = $rev->getParentId();
303  if ( $parentId ) {
304  $revision['parent_id'] = $parentId;
305  }
306 
307  $comment = $rev->getComment( RevisionRecord::FOR_THIS_USER, $this->user );
308  $revision['comment'] = $comment ? $comment->text : null;
309 
310  $revUser = $rev->getUser( RevisionRecord::FOR_THIS_USER, $this->user );
311  if ( $revUser ) {
312  $revision['user'] = [
313  'id' => $revUser->isRegistered() ? $revUser->getId() : null,
314  'name' => $revUser->getName()
315  ];
316  } else {
317  $revision['user'] = null;
318  }
319 
320  $revisions[] = $revision;
321 
322  // Break manually at the return limit. We may have more results than we can return.
323  if ( count( $revisions ) == self::REVISIONS_RETURN_LIMIT ) {
324  break;
325  }
326  }
327 
328  // Request any parent sizes that we do not already know, then calculate deltas
329  $unknownSizes = [];
330  foreach ( $revisions as $revision ) {
331  if ( isset( $revision['parent_id'] ) && !isset( $sizes[$revision['parent_id']] ) ) {
332  $unknownSizes[] = $revision['parent_id'];
333  }
334  }
335  if ( $unknownSizes ) {
336  $sizes += $this->revisionStore->getRevisionSizes( $unknownSizes );
337  }
338  foreach ( $revisions as &$revision ) {
339  $revision['delta'] = null;
340  if ( isset( $revision['parent_id'] ) ) {
341  if ( isset( $sizes[$revision['parent_id']] ) ) {
342  $revision['delta'] = $revision['size'] - $sizes[$revision['parent_id']];
343  }
344 
345  // We only remembered this for delta calculations. We do not want to return it.
346  unset( $revision['parent_id'] );
347  }
348  }
349 
350  if ( $revisions && $params['newer_than'] ) {
351  $revisions = array_reverse( $revisions );
352  $temp = $lastRevId;
353  $lastRevId = $firstRevId;
354  $firstRevId = $temp;
355  }
356  }
357 
358  $response = [
359  'revisions' => $revisions
360  ];
361 
362  // Omit newer/older if there are no additional corresponding revisions.
363  // This facilitates clients doing "paging" style api operations.
364  if ( $revisions ) {
365  if ( $params['newer_than'] || $res->numRows() > self::REVISIONS_RETURN_LIMIT ) {
366  $older = $lastRevId;
367  }
368  if ( $params['older_than'] ||
369  ( $params['newer_than'] && $res->numRows() > self::REVISIONS_RETURN_LIMIT )
370  ) {
371  $newer = $firstRevId;
372  }
373  }
374 
375  $wr = new \WebRequest();
376  $urlParts = wfParseUrl( $wr->getFullRequestURL() );
377  if ( $urlParts ) {
378  $queryParts = wfCgiToArray( $urlParts['query'] );
379  unset( $urlParts['query'] );
380  unset( $queryParts['older_than'] );
381  unset( $queryParts['newer_than'] );
382 
383  $uri = Uri::fromParts( $urlParts );
384  $response['latest'] = Uri::withQueryValues( $uri, $queryParts )->__toString();
385  if ( isset( $older ) ) {
386  $response['older'] = Uri::withQueryValues(
387  $uri,
388  $queryParts + [ 'older_than' => $older ]
389  )->__toString();
390  }
391  if ( isset( $newer ) ) {
392  $response['newer'] = Uri::withQueryValues(
393  $uri,
394  $queryParts + [ 'newer_than' => $newer ]
395  )->__toString();
396  }
397  }
398 
399  return $response;
400  }
401 
402  public function needsWriteAccess() {
403  return false;
404  }
405 
406  public function getParamSettings() {
407  return [
408  'title' => [
409  self::PARAM_SOURCE => 'path',
410  ParamValidator::PARAM_TYPE => 'string',
411  ParamValidator::PARAM_REQUIRED => true,
412  ],
413  'older_than' => [
414  self::PARAM_SOURCE => 'query',
415  ParamValidator::PARAM_TYPE => 'integer',
416  ParamValidator::PARAM_REQUIRED => false,
417  ],
418  'newer_than' => [
419  self::PARAM_SOURCE => 'query',
420  ParamValidator::PARAM_TYPE => 'integer',
421  ParamValidator::PARAM_REQUIRED => false,
422  ],
423  'filter' => [
424  self::PARAM_SOURCE => 'query',
425  ParamValidator::PARAM_TYPE => self::ALLOWED_FILTER_TYPES,
426  ParamValidator::PARAM_REQUIRED => false,
427  ],
428  ];
429  }
430 }
Handler class for Core REST API endpoints that perform operations on revisions.
$response
getArticleID( $flags=0)
Get the article ID for this Title from the link cache, adding it if necessary.
Definition: Title.php:3161
Exception representing a failure to look up a row from a name table.
wfParseUrl( $url)
parse_url() work-alike, but non-broken.
getDbResults(Title $titleObj, array $params, $relativeRevId, $ts, $tagIds)
getChangeTagDef( $wiki=false)
Get a NameTableStore for the change_tag_def table.
Value object representing a message for i18n.
getBitmask()
Helper function for rev_deleted/user rights query conditions.
The User object encapsulates all of the user-specific settings (user_id, name, rights, email address, options, last login time).
Definition: User.php:51
getResponseFactory()
Get the ResponseFactory which can be used to generate Response objects.
Definition: Handler.php:92
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
static getMain()
Get the RequestContext object associated with the main request.
wfCgiToArray( $query)
This is the logical opposite of wfArrayToCgi(): it accepts a query string as its argument and returns...
Service for looking up page revisions.
Database cluster connection, tracking, load balancing, and transaction manager interface.
$revQuery
__construct(RevisionStore $revisionStore, NameTableStoreFactory $nameTableStoreFactory, PermissionManager $permissionManager, ILoadBalancer $loadBalancer)
const DB_REPLICA
Definition: defines.php:25
getValidatedParams()
Fetch the validated parameters.
Definition: Handler.php:174
Value object representing a message parameter holding a single value.
Definition: ScalarParam.php:10
run( $title)
At most one of older_than and newer_than may be specified.
A service class for checking permissions To obtain an instance, use MediaWikiServices::getInstance()-...
static newFromText( $text, $defaultNamespace=NS_MAIN)
Create a new Title from text, such as what one would find in a link.
Definition: Title.php:317