1<?php 2 3namespace MediaWiki\Rest\Handler; 4 5use ChangeTags; 6use IDBAccessObject; 7use MediaWiki\Permissions\PermissionManager; 8use MediaWiki\Rest\LocalizedHttpException; 9use MediaWiki\Rest\Response; 10use MediaWiki\Rest\SimpleHandler; 11use MediaWiki\Revision\RevisionRecord; 12use MediaWiki\Revision\RevisionStore; 13use MediaWiki\Storage\NameTableAccessException; 14use MediaWiki\Storage\NameTableStore; 15use MediaWiki\Storage\NameTableStoreFactory; 16use Title; 17use Wikimedia\Message\MessageValue; 18use Wikimedia\Message\ParamType; 19use Wikimedia\Message\ScalarParam; 20use Wikimedia\ParamValidator\ParamValidator; 21use Wikimedia\Rdbms\ILoadBalancer; 22use Wikimedia\Rdbms\IResultWrapper; 23 24/** 25 * Handler class for Core REST API endpoints that perform operations on revisions 26 */ 27class PageHistoryHandler extends SimpleHandler { 28 private const REVISIONS_RETURN_LIMIT = 20; 29 private const ALLOWED_FILTER_TYPES = [ 'anonymous', 'bot', 'reverted', 'minor' ]; 30 31 /** @var RevisionStore */ 32 private $revisionStore; 33 34 /** @var NameTableStore */ 35 private $changeTagDefStore; 36 37 /** @var PermissionManager */ 38 private $permissionManager; 39 40 /** @var ILoadBalancer */ 41 private $loadBalancer; 42 43 /** 44 * @var Title|bool|null 45 */ 46 private $title = null; 47 48 /** 49 * RevisionStore $revisionStore 50 * 51 * @param RevisionStore $revisionStore 52 * @param NameTableStoreFactory $nameTableStoreFactory 53 * @param PermissionManager $permissionManager 54 * @param ILoadBalancer $loadBalancer 55 */ 56 public function __construct( 57 RevisionStore $revisionStore, 58 NameTableStoreFactory $nameTableStoreFactory, 59 PermissionManager $permissionManager, 60 ILoadBalancer $loadBalancer 61 ) { 62 $this->revisionStore = $revisionStore; 63 $this->changeTagDefStore = $nameTableStoreFactory->getChangeTagDef(); 64 $this->permissionManager = $permissionManager; 65 $this->loadBalancer = $loadBalancer; 66 } 67 68 /** 69 * @return Title|bool Title or false if unable to retrieve title 70 */ 71 private function getTitle() { 72 if ( $this->title === null ) { 73 $this->title = Title::newFromText( $this->getValidatedParams()['title'] ) ?? false; 74 } 75 return $this->title; 76 } 77 78 /** 79 * At most one of older_than and newer_than may be specified. Keep in mind that revision ids 80 * are not monotonically increasing, so a revision may be older than another but have a 81 * higher revision id. 82 * 83 * @param string $title 84 * @return Response 85 * @throws LocalizedHttpException 86 */ 87 public function run( $title ) { 88 $params = $this->getValidatedParams(); 89 if ( $params['older_than'] !== null && $params['newer_than'] !== null ) { 90 throw new LocalizedHttpException( 91 new MessageValue( 'rest-pagehistory-incompatible-params' ), 400 ); 92 } 93 94 if ( ( $params['older_than'] !== null && $params['older_than'] < 1 ) || 95 ( $params['newer_than'] !== null && $params['newer_than'] < 1 ) 96 ) { 97 throw new LocalizedHttpException( 98 new MessageValue( 'rest-pagehistory-param-range-error' ), 400 ); 99 } 100 101 $tagIds = []; 102 if ( $params['filter'] === 'reverted' ) { 103 foreach ( ChangeTags::REVERT_TAGS as $tagName ) { 104 try { 105 $tagIds[] = $this->changeTagDefStore->getId( $tagName ); 106 } catch ( NameTableAccessException $exception ) { 107 // If no revisions are tagged with a name, no tag id will be present 108 } 109 } 110 } 111 112 $titleObj = Title::newFromText( $title ); 113 if ( !$titleObj || !$titleObj->getArticleID() ) { 114 throw new LocalizedHttpException( 115 new MessageValue( 'rest-nonexistent-title', 116 [ new ScalarParam( ParamType::PLAINTEXT, $title ) ] 117 ), 118 404 119 ); 120 } 121 if ( !$this->getAuthority()->authorizeRead( 'read', $titleObj ) ) { 122 throw new LocalizedHttpException( 123 new MessageValue( 'rest-permission-denied-title', 124 [ new ScalarParam( ParamType::PLAINTEXT, $title ) ] ), 125 403 126 ); 127 } 128 129 $relativeRevId = $params['older_than'] ?? $params['newer_than'] ?? 0; 130 if ( $relativeRevId ) { 131 // Confirm the relative revision exists for this page. If so, get its timestamp. 132 $rev = $this->revisionStore->getRevisionByPageId( 133 $titleObj->getArticleID(), 134 $relativeRevId 135 ); 136 if ( !$rev ) { 137 throw new LocalizedHttpException( 138 new MessageValue( 'rest-nonexistent-title-revision', 139 [ $relativeRevId, new ScalarParam( ParamType::PLAINTEXT, $title ) ] 140 ), 141 404 142 ); 143 } 144 $ts = $rev->getTimestamp(); 145 if ( $ts === null ) { 146 throw new LocalizedHttpException( 147 new MessageValue( 'rest-pagehistory-timestamp-error', 148 [ $relativeRevId ] 149 ), 150 500 151 ); 152 } 153 } else { 154 $ts = 0; 155 } 156 157 $res = $this->getDbResults( $titleObj, $params, $relativeRevId, $ts, $tagIds ); 158 $response = $this->processDbResults( $res, $titleObj, $params ); 159 return $this->getResponseFactory()->createJson( $response ); 160 } 161 162 /** 163 * @param Title $titleObj title object identifying the page to load history for 164 * @param array $params request parameters 165 * @param int $relativeRevId relative revision id for paging, or zero if none 166 * @param int $ts timestamp for paging, or zero if none 167 * @param array $tagIds validated tags ids, or empty array if not needed for this query 168 * @return IResultWrapper|bool the results, or false if no query was executed 169 */ 170 private function getDbResults( Title $titleObj, array $params, $relativeRevId, $ts, $tagIds ) { 171 $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA ); 172 $revQuery = $this->revisionStore->getQueryInfo(); 173 $cond = [ 174 'rev_page' => $titleObj->getArticleID() 175 ]; 176 177 if ( $params['filter'] ) { 178 // This redundant join condition tells MySQL that rev_page and revactor_page are the 179 // same, so it can propagate the condition 180 $revQuery['joins']['temp_rev_user'][1] = 181 "temp_rev_user.revactor_rev = rev_id AND revactor_page = rev_page"; 182 183 // The validator ensures this value, if present, is one of the expected values 184 switch ( $params['filter'] ) { 185 case 'bot': 186 $cond[] = 'EXISTS(' . $dbr->selectSQLText( 187 'user_groups', 188 '1', 189 [ 190 'actor_rev_user.actor_user = ug_user', 191 'ug_group' => $this->permissionManager->getGroupsWithPermission( 'bot' ), 192 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() ) 193 ], 194 __METHOD__ 195 ) . ')'; 196 $bitmask = $this->getBitmask(); 197 if ( $bitmask ) { 198 $cond[] = $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask"; 199 } 200 break; 201 202 case 'anonymous': 203 $cond[] = "actor_user IS NULL"; 204 $bitmask = $this->getBitmask(); 205 if ( $bitmask ) { 206 $cond[] = $dbr->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask"; 207 } 208 break; 209 210 case 'reverted': 211 if ( !$tagIds ) { 212 return false; 213 } 214 $cond[] = 'EXISTS(' . $dbr->selectSQLText( 215 'change_tag', 216 '1', 217 [ 'ct_rev_id = rev_id', 'ct_tag_id' => $tagIds ], 218 __METHOD__ 219 ) . ')'; 220 break; 221 222 case 'minor': 223 $cond[] = 'rev_minor_edit != 0'; 224 break; 225 } 226 } 227 228 if ( $relativeRevId ) { 229 $op = $params['older_than'] ? '<' : '>'; 230 $sort = $params['older_than'] ? 'DESC' : 'ASC'; 231 $ts = $dbr->addQuotes( $dbr->timestamp( $ts ) ); 232 $cond[] = "rev_timestamp $op $ts OR " . 233 "(rev_timestamp = $ts AND rev_id $op $relativeRevId)"; 234 $orderBy = "rev_timestamp $sort, rev_id $sort"; 235 } else { 236 $orderBy = "rev_timestamp DESC, rev_id DESC"; 237 } 238 239 // Select one more than the return limit, to learn if there are additional revisions. 240 $limit = self::REVISIONS_RETURN_LIMIT + 1; 241 242 $res = $dbr->select( 243 $revQuery['tables'], 244 $revQuery['fields'], 245 $cond, 246 __METHOD__, 247 [ 248 'ORDER BY' => $orderBy, 249 'LIMIT' => $limit, 250 ], 251 $revQuery['joins'] 252 ); 253 254 return $res; 255 } 256 257 /** 258 * Helper function for rev_deleted/user rights query conditions 259 * 260 * @todo Factor out rev_deleted logic per T233222 261 * 262 * @return int 263 */ 264 private function getBitmask() { 265 if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) { 266 $bitmask = RevisionRecord::DELETED_USER; 267 } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) { 268 $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED; 269 } else { 270 $bitmask = 0; 271 } 272 return $bitmask; 273 } 274 275 /** 276 * @param IResultWrapper|bool $res database results, or false if no query was executed 277 * @param Title $titleObj title object identifying the page to load history for 278 * @param array $params request parameters 279 * @return array response data 280 */ 281 private function processDbResults( $res, $titleObj, $params ) { 282 $revisions = []; 283 284 if ( $res ) { 285 $sizes = []; 286 foreach ( $res as $row ) { 287 $rev = $this->revisionStore->newRevisionFromRow( 288 $row, 289 IDBAccessObject::READ_NORMAL, 290 $titleObj 291 ); 292 if ( !$revisions ) { 293 $firstRevId = $row->rev_id; 294 } 295 $lastRevId = $row->rev_id; 296 297 $revision = [ 298 'id' => $rev->getId(), 299 'timestamp' => wfTimestamp( TS_ISO_8601, $rev->getTimestamp() ), 300 'minor' => $rev->isMinor(), 301 'size' => $rev->getSize() 302 ]; 303 304 // Remember revision sizes and parent ids for calculating deltas. If a revision's 305 // parent id is unknown, we will be unable to supply the delta for that revision. 306 $sizes[$rev->getId()] = $rev->getSize(); 307 $parentId = $rev->getParentId(); 308 if ( $parentId ) { 309 $revision['parent_id'] = $parentId; 310 } 311 312 $comment = $rev->getComment( RevisionRecord::FOR_THIS_USER, $this->getAuthority() ); 313 $revision['comment'] = $comment ? $comment->text : null; 314 315 $revUser = $rev->getUser( RevisionRecord::FOR_THIS_USER, $this->getAuthority() ); 316 if ( $revUser ) { 317 $revision['user'] = [ 318 'id' => $revUser->isRegistered() ? $revUser->getId() : null, 319 'name' => $revUser->getName() 320 ]; 321 } else { 322 $revision['user'] = null; 323 } 324 325 $revisions[] = $revision; 326 327 // Break manually at the return limit. We may have more results than we can return. 328 if ( count( $revisions ) == self::REVISIONS_RETURN_LIMIT ) { 329 break; 330 } 331 } 332 333 // Request any parent sizes that we do not already know, then calculate deltas 334 $unknownSizes = []; 335 foreach ( $revisions as $revision ) { 336 if ( isset( $revision['parent_id'] ) && !isset( $sizes[$revision['parent_id']] ) ) { 337 $unknownSizes[] = $revision['parent_id']; 338 } 339 } 340 if ( $unknownSizes ) { 341 $sizes += $this->revisionStore->getRevisionSizes( $unknownSizes ); 342 } 343 foreach ( $revisions as &$revision ) { 344 $revision['delta'] = null; 345 if ( isset( $revision['parent_id'] ) ) { 346 if ( isset( $sizes[$revision['parent_id']] ) ) { 347 $revision['delta'] = $revision['size'] - $sizes[$revision['parent_id']]; 348 } 349 350 // We only remembered this for delta calculations. We do not want to return it. 351 unset( $revision['parent_id'] ); 352 } 353 } 354 355 if ( $revisions && $params['newer_than'] ) { 356 $revisions = array_reverse( $revisions ); 357 $temp = $lastRevId; 358 $lastRevId = $firstRevId; 359 $firstRevId = $temp; 360 } 361 } 362 363 $response = [ 364 'revisions' => $revisions 365 ]; 366 367 // Omit newer/older if there are no additional corresponding revisions. 368 // This facilitates clients doing "paging" style api operations. 369 if ( $revisions ) { 370 if ( $params['newer_than'] || $res->numRows() > self::REVISIONS_RETURN_LIMIT ) { 371 $older = $lastRevId; 372 } 373 if ( $params['older_than'] || 374 ( $params['newer_than'] && $res->numRows() > self::REVISIONS_RETURN_LIMIT ) 375 ) { 376 $newer = $firstRevId; 377 } 378 } 379 380 $queryParts = []; 381 382 if ( isset( $params['filter'] ) ) { 383 $queryParts['filter'] = $params['filter']; 384 } 385 386 $pathParams = [ 'title' => $titleObj->getPrefixedDBkey() ]; 387 388 $response['latest'] = $this->getRouteUrl( $pathParams, $queryParts ); 389 390 if ( isset( $older ) ) { 391 $response['older'] = 392 $this->getRouteUrl( $pathParams, $queryParts + [ 'older_than' => $older ] ); 393 } 394 if ( isset( $newer ) ) { 395 $response['newer'] = 396 $this->getRouteUrl( $pathParams, $queryParts + [ 'newer_than' => $newer ] ); 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 431 /** 432 * Returns an ETag representing a page's latest revision. 433 * 434 * @return string|null 435 */ 436 protected function getETag(): ?string { 437 $title = $this->getTitle(); 438 if ( !$title || !$title->getArticleID() ) { 439 return null; 440 } 441 442 return '"' . $title->getLatestRevID() . '"'; 443 } 444 445 /** 446 * Returns the time of the last change to the page. 447 * 448 * @return string|null 449 */ 450 protected function getLastModified(): ?string { 451 $title = $this->getTitle(); 452 if ( !$title || !$title->getArticleID() ) { 453 return null; 454 } 455 456 $rev = $this->revisionStore->getKnownCurrentRevision( $title ); 457 return $rev->getTimestamp(); 458 } 459 460 /** 461 * @return bool 462 */ 463 protected function hasRepresentation() { 464 $title = $this->getTitle(); 465 return $title ? $title->exists() : false; 466 } 467} 468