MediaWiki REL1_37
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
35
38
41
44
46 private $pageLookup;
47
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 $revQuery['tables'],
262 $revQuery['fields'],
263 $cond,
264 __METHOD__,
265 [
266 'ORDER BY' => $orderBy,
267 'LIMIT' => $limit,
268 ],
269 $revQuery['joins']
270 );
271
272 return $res;
273 }
274
282 private function getBitmask() {
283 if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
284 $bitmask = RevisionRecord::DELETED_USER;
285 } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
286 $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED;
287 } else {
288 $bitmask = 0;
289 }
290 return $bitmask;
291 }
292
299 private function processDbResults( $res, $page, $params ) {
300 $revisions = [];
301
302 if ( $res ) {
303 $sizes = [];
304 foreach ( $res as $row ) {
305 $rev = $this->revisionStore->newRevisionFromRow(
306 $row,
307 IDBAccessObject::READ_NORMAL,
308 $page
309 );
310 if ( !$revisions ) {
311 $firstRevId = $row->rev_id;
312 }
313 $lastRevId = $row->rev_id;
314
315 $revision = [
316 'id' => $rev->getId(),
317 'timestamp' => wfTimestamp( TS_ISO_8601, $rev->getTimestamp() ),
318 'minor' => $rev->isMinor(),
319 'size' => $rev->getSize()
320 ];
321
322 // Remember revision sizes and parent ids for calculating deltas. If a revision's
323 // parent id is unknown, we will be unable to supply the delta for that revision.
324 $sizes[$rev->getId()] = $rev->getSize();
325 $parentId = $rev->getParentId();
326 if ( $parentId ) {
327 $revision['parent_id'] = $parentId;
328 }
329
330 $comment = $rev->getComment( RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
331 $revision['comment'] = $comment ? $comment->text : null;
332
333 $revUser = $rev->getUser( RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
334 if ( $revUser ) {
335 $revision['user'] = [
336 'id' => $revUser->isRegistered() ? $revUser->getId() : null,
337 'name' => $revUser->getName()
338 ];
339 } else {
340 $revision['user'] = null;
341 }
342
343 $revisions[] = $revision;
344
345 // Break manually at the return limit. We may have more results than we can return.
346 if ( count( $revisions ) == self::REVISIONS_RETURN_LIMIT ) {
347 break;
348 }
349 }
350
351 // Request any parent sizes that we do not already know, then calculate deltas
352 $unknownSizes = [];
353 foreach ( $revisions as $revision ) {
354 if ( isset( $revision['parent_id'] ) && !isset( $sizes[$revision['parent_id']] ) ) {
355 $unknownSizes[] = $revision['parent_id'];
356 }
357 }
358 if ( $unknownSizes ) {
359 $sizes += $this->revisionStore->getRevisionSizes( $unknownSizes );
360 }
361 foreach ( $revisions as &$revision ) {
362 $revision['delta'] = null;
363 if ( isset( $revision['parent_id'] ) ) {
364 if ( isset( $sizes[$revision['parent_id']] ) ) {
365 $revision['delta'] = $revision['size'] - $sizes[$revision['parent_id']];
366 }
367
368 // We only remembered this for delta calculations. We do not want to return it.
369 unset( $revision['parent_id'] );
370 }
371 }
372
373 if ( $revisions && $params['newer_than'] ) {
374 $revisions = array_reverse( $revisions );
375 $temp = $lastRevId;
376 $lastRevId = $firstRevId;
377 $firstRevId = $temp;
378 }
379 }
380
381 $response = [
382 'revisions' => $revisions
383 ];
384
385 // Omit newer/older if there are no additional corresponding revisions.
386 // This facilitates clients doing "paging" style api operations.
387 if ( $revisions ) {
388 if ( $params['newer_than'] || $res->numRows() > self::REVISIONS_RETURN_LIMIT ) {
389 $older = $lastRevId;
390 }
391 if ( $params['older_than'] ||
392 ( $params['newer_than'] && $res->numRows() > self::REVISIONS_RETURN_LIMIT )
393 ) {
394 $newer = $firstRevId;
395 }
396 }
397
398 $queryParts = [];
399
400 if ( isset( $params['filter'] ) ) {
401 $queryParts['filter'] = $params['filter'];
402 }
403
404 $pathParams = [ 'title' => $this->titleFormatter->getPrefixedDBkey( $page ) ];
405
406 $response['latest'] = $this->getRouteUrl( $pathParams, $queryParts );
407
408 if ( isset( $older ) ) {
409 $response['older'] =
410 $this->getRouteUrl( $pathParams, $queryParts + [ 'older_than' => $older ] );
411 }
412 if ( isset( $newer ) ) {
413 $response['newer'] =
414 $this->getRouteUrl( $pathParams, $queryParts + [ 'newer_than' => $newer ] );
415 }
416
417 return $response;
418 }
419
420 public function needsWriteAccess() {
421 return false;
422 }
423
424 public function getParamSettings() {
425 return [
426 'title' => [
427 self::PARAM_SOURCE => 'path',
428 ParamValidator::PARAM_TYPE => 'string',
429 ParamValidator::PARAM_REQUIRED => true,
430 ],
431 'older_than' => [
432 self::PARAM_SOURCE => 'query',
433 ParamValidator::PARAM_TYPE => 'integer',
434 ParamValidator::PARAM_REQUIRED => false,
435 ],
436 'newer_than' => [
437 self::PARAM_SOURCE => 'query',
438 ParamValidator::PARAM_TYPE => 'integer',
439 ParamValidator::PARAM_REQUIRED => false,
440 ],
441 'filter' => [
442 self::PARAM_SOURCE => 'query',
443 ParamValidator::PARAM_TYPE => self::ALLOWED_FILTER_TYPES,
444 ParamValidator::PARAM_REQUIRED => false,
445 ],
446 ];
447 }
448
454 protected function getETag(): ?string {
455 $page = $this->getPage();
456 if ( !$page ) {
457 return null;
458 }
459
460 return '"' . $page->getLatest() . '"';
461 }
462
468 protected function getLastModified(): ?string {
469 $page = $this->getPage();
470 if ( !$page ) {
471 return null;
472 }
473
474 $rev = $this->revisionStore->getKnownCurrentRevision( $page );
475 return $rev->getTimestamp();
476 }
477
481 protected function hasRepresentation() {
482 return (bool)$this->getPage();
483 }
484}
getAuthority()
wfTimestamp( $outputtype=TS_UNIX, $ts=0)
Get a timestamp string in one of various formats.
if(ini_get('mbstring.func_overload')) if(!defined('MW_ENTRY_POINT'))
Pre-config setup: Before loading LocalSettings.php.
Definition Setup.php:88
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.
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:101
getValidatedParams()
Fetch the validated parameters.
Definition Handler.php:282
getResponseFactory()
Get the ResponseFactory which can be used to generate Response objects.
Definition Handler.php:170
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 interface for looking up infermation about wiki pages.
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.
const DB_REPLICA
Definition defines.php:25