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 
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  // The validator ensures this value, if present, is one of the expected values
195  switch ( $params['filter'] ) {
196  case 'bot':
197  $cond[] = 'EXISTS(' . $dbr->selectSQLText(
198  'user_groups',
199  '1',
200  [
201  'actor_rev_user.actor_user = ug_user',
202  'ug_group' => $this->groupPermissionsLookup->getGroupsWithPermission( 'bot' ),
203  'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() )
204  ],
205  __METHOD__
206  ) . ')';
207  $bitmask = $this->getBitmask();
208  if ( $bitmask ) {
209  $cond[] = $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask";
210  }
211  break;
212 
213  case 'anonymous':
214  $cond[] = "actor_user IS NULL";
215  $bitmask = $this->getBitmask();
216  if ( $bitmask ) {
217  $cond[] = $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask";
218  }
219  break;
220 
221  case 'reverted':
222  if ( !$tagIds ) {
223  return false;
224  }
225  $cond[] = 'EXISTS(' . $dbr->selectSQLText(
226  'change_tag',
227  '1',
228  [ 'ct_rev_id = rev_id', 'ct_tag_id' => $tagIds ],
229  __METHOD__
230  ) . ')';
231  break;
232 
233  case 'minor':
234  $cond[] = 'rev_minor_edit != 0';
235  break;
236  }
237  }
238 
239  if ( $relativeRevId ) {
240  $op = $params['older_than'] ? '<' : '>';
241  $sort = $params['older_than'] ? 'DESC' : 'ASC';
242  $cond[] = $dbr->buildComparison( $op, [
243  'rev_timestamp' => $dbr->timestamp( $ts ),
244  'rev_id' => $relativeRevId,
245  ] );
246  $orderBy = "rev_timestamp $sort, rev_id $sort";
247  } else {
248  $orderBy = "rev_timestamp DESC, rev_id DESC";
249  }
250 
251  // Select one more than the return limit, to learn if there are additional revisions.
252  $limit = self::REVISIONS_RETURN_LIMIT + 1;
253 
254  $res = $dbr->select(
255  $revQuery['tables'],
256  $revQuery['fields'],
257  $cond,
258  __METHOD__,
259  [
260  'ORDER BY' => $orderBy,
261  'LIMIT' => $limit,
262  ],
263  $revQuery['joins']
264  );
265 
266  return $res;
267  }
268 
276  private function getBitmask() {
277  if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
278  $bitmask = RevisionRecord::DELETED_USER;
279  } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
281  } else {
282  $bitmask = 0;
283  }
284  return $bitmask;
285  }
286 
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',
432  ],
433  'older_than' => [
434  self::PARAM_SOURCE => 'query',
435  ParamValidator::PARAM_TYPE => 'integer',
437  ],
438  'newer_than' => [
439  self::PARAM_SOURCE => 'query',
440  ParamValidator::PARAM_TYPE => 'integer',
442  ],
443  'filter' => [
444  self::PARAM_SOURCE => 'query',
445  ParamValidator::PARAM_TYPE => self::ALLOWED_FILTER_TYPES,
447  ],
448  ];
449  }
450 
456  protected function getETag(): ?string {
457  $page = $this->getPage();
458  if ( !$page ) {
459  return null;
460  }
461 
462  return '"' . $page->getLatest() . '"';
463  }
464 
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 
483  protected function hasRepresentation() {
484  return (bool)$this->getPage();
485  }
486 }
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:97
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:358
getAuthority()
Get the current acting authority.
Definition: Handler.php:157
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.
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:17
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
$revQuery