MediaWiki  master
PageHistoryHandler.php
Go to the documentation of this file.
1 <?php
2 
3 namespace MediaWiki\Rest\Handler;
4 
5 use ChangeTags;
18 use TitleFormatter;
25 
30  private const REVISIONS_RETURN_LIMIT = 20;
31  private const ALLOWED_FILTER_TYPES = [ 'anonymous', 'bot', 'reverted', 'minor' ];
32 
34  private $revisionStore;
35 
38 
41 
43  private $loadBalancer;
44 
46  private $pageLookup;
47 
49  private $titleFormatter;
50 
54  private $page = false;
55 
66  public function __construct(
68  NameTableStoreFactory $nameTableStoreFactory,
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' ) ) {
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',
439  ],
440  'older_than' => [
441  self::PARAM_SOURCE => 'query',
442  ParamValidator::PARAM_TYPE => 'integer',
444  ],
445  'newer_than' => [
446  self::PARAM_SOURCE => 'query',
447  ParamValidator::PARAM_TYPE => 'integer',
449  ],
450  'filter' => [
451  self::PARAM_SOURCE => 'query',
452  ParamValidator::PARAM_TYPE => self::ALLOWED_FILTER_TYPES,
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 }
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.
Definition: ChangeTags.php:96
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.
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.
getDbResults(ExistingPageRecord $page, array $params, $relativeRevId, $ts, $tagIds)
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:109
getValidatedParams()
Fetch the validated parameters.
Definition: Handler.php:322
getAuthority()
Get the current acting authority.
Definition: Handler.php:156
getResponseFactory()
Get the ResponseFactory which can be used to generate Response objects.
Definition: Handler.php:178
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.
Definition: ScalarParam.php:14
Service for formatting and validating API parameters.
const PARAM_TYPE
(string|array) Type of the parameter.
const PARAM_REQUIRED
(bool) Indicate that the parameter is required.
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.
Definition: PageLookup.php:16
getId( $wikiId=self::LOCAL)
Returns the page ID.
A title formatter service for MediaWiki.
Database cluster connection, tracking, load balancing, and transaction manager interface.
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
$revQuery