diff options
-rw-r--r-- | includes/Rest/Handler/PageHistoryHandler.php | 394 | ||||
-rw-r--r-- | includes/Rest/coreRoutes.json | 10 | ||||
-rw-r--r-- | languages/i18n/en.json | 9 | ||||
-rw-r--r-- | languages/i18n/qqq.json | 9 |
4 files changed, 420 insertions, 2 deletions
diff --git a/includes/Rest/Handler/PageHistoryHandler.php b/includes/Rest/Handler/PageHistoryHandler.php new file mode 100644 index 000000000000..32229f00ebef --- /dev/null +++ b/includes/Rest/Handler/PageHistoryHandler.php @@ -0,0 +1,394 @@ +<?php + +namespace MediaWiki\Rest\Handler; + +use GuzzleHttp\Psr7\Uri; +use IDBAccessObject; +use MediaWiki\Permissions\PermissionManager; +use MediaWiki\Rest\LocalizedHttpException; +use MediaWiki\Rest\SimpleHandler; +use MediaWiki\Rest\Response; +use MediaWiki\Revision\RevisionRecord; +use MediaWiki\Revision\RevisionStore; +use Message; +use Wikimedia\Message\MessageValue; +use MediaWiki\Storage\NameTableAccessException; +use MediaWiki\Storage\NameTableStore; +use MediaWiki\Storage\NameTableStoreFactory; +use Wikimedia\ParamValidator\ParamValidator; +use Wikimedia\Rdbms\ILoadBalancer; +use Wikimedia\Rdbms\IResultWrapper; +use Title; + +/** + * Handler class for Core REST API endpoints that perform operations on revisions + */ +class PageHistoryHandler extends SimpleHandler { + const REVISIONS_RETURN_LIMIT = 20; + const REVERTED_TAG_NAMES = [ 'mw-undo', 'mw-rollback' ]; + const ALLOWED_FILTER_TYPES = [ 'anonymous', 'bot', 'reverted' ]; + + /** @var RevisionStore */ + private $revisionStore; + + /** @var NameTableStore */ + private $changeTagDefStore; + + /** @var PermissionManager */ + private $permissionManager; + + /** @var ILoadBalancer */ + private $loadBalancer; + + /** + * @param RevisionStore $revisionStore + * @param NameTableStoreFactory $nameTableStoreFactory + * @param PermissionManager $permissionManager + * @param ILoadBalancer $loadBalancer + */ + public function __construct( + RevisionStore $revisionStore, + NameTableStoreFactory $nameTableStoreFactory, + PermissionManager $permissionManager, + ILoadBalancer $loadBalancer + ) { + $this->revisionStore = $revisionStore; + $this->changeTagDefStore = $nameTableStoreFactory->getChangeTagDef(); + $this->permissionManager = $permissionManager; + $this->loadBalancer = $loadBalancer; + } + + /** + * At most one of older_than and newer_than may be specified. Keep in mind that revision ids + * are not monotonically increasing, so a revision may be older than another but have a + * higher revision id. + * + * @param string $title + * @return Response + * @throws LocalizedHttpException + */ + public function run( $title ) { + $params = $this->getValidatedParams(); + if ( $params['older_than'] !== null && $params['newer_than'] !== null ) { + throw new LocalizedHttpException( + new MessageValue( 'rest-pagehistory-incompatible-params' ), 400 ); + } + + if ( ( $params['older_than'] !== null && $params['older_than'] < 1 ) || + ( $params['newer_than'] !== null && $params['newer_than'] < 1 ) + ) { + throw new LocalizedHttpException( + new MessageValue( 'rest-pagehistory-param-range-error' ), 400 ); + } + + $tagIds = []; + if ( $params['filter'] === 'reverted' ) { + foreach ( self::REVERTED_TAG_NAMES as $tagName ) { + try { + $tagIds[] = $this->changeTagDefStore->getId( $tagName ); + } catch ( NameTableAccessException $exception ) { + // If no revisions are tagged with a name, no tag id will be present + } + } + } + + $titleObj = Title::newFromText( $title ); + if ( !$titleObj || !$titleObj->getArticleID() ) { + throw new LocalizedHttpException( + new MessageValue( 'rest-pagehistory-title-nonexistent', + [ Message::plaintextParam( $title ) ] + ), + 404 + ); + } + + $relativeRevId = $params['older_than'] ?? $params['newer_than'] ?? 0; + if ( $relativeRevId ) { + // Confirm the relative revision exists for this page. If so, get its timestamp. + $rev = $this->revisionStore->getRevisionByPageId( + $titleObj->getArticleID(), + $relativeRevId + ); + if ( !$rev ) { + throw new LocalizedHttpException( + new MessageValue( 'rest-pagehistory-revision-nonexistent', + [ $relativeRevId, Message::plaintextParam( $title ) ] + ), + 404 + ); + } + $ts = $rev->getTimestamp(); + if ( $ts === null ) { + throw new LocalizedHttpException( + new MessageValue( 'rest-pagehistory-timestamp-error', + [ $relativeRevId ] + ), + 500 + ); + } + } else { + $ts = 0; + } + + $res = $this->getDbResults( $titleObj, $params, $relativeRevId, $ts, $tagIds ); + if ( $res === false || $res->numRows() == 0 ) { + if ( $relativeRevId ) { + throw new LocalizedHttpException( + new MessageValue( 'rest-pagehistory-revisions-nonexistent-with-params', + [ Message::plaintextParam( $title ) ] + ), + 404 + ); + } else { + throw new LocalizedHttpException( + new MessageValue( 'rest-pagehistory-revisions-nonexistent', + [ Message::plaintextParam( $title ) ] ), + 404 + ); + } + } + + // If we make it here, we will always have at least one revision to return. + $response = $this->processDbResults( $res, $titleObj, $params ); + return $this->getResponseFactory()->createJson( $response ); + } + + /** + * @param Title $titleObj title object identifying the page to load history for + * @param array $params request parameters + * @param int $relativeRevId relative revision id for paging, or zero if none + * @param int $ts timestamp for paging, or zero if none + * @param array $tagIds validated tags ids, or empty array if not needed for this query + * @return IResultWrapper|bool the results, or false if no query was executed + */ + private function getDbResults( Title $titleObj, array $params, $relativeRevId, $ts, $tagIds ) { + $dbr = $this->loadBalancer->getConnectionRef( DB_REPLICA ); + $revQuery = $this->revisionStore->getQueryInfo(); + $cond = [ + 'rev_page' => $titleObj->getArticleID() + ]; + + if ( $params['filter'] ) { + // This redundant join condition tells MySQL that rev_page and revactor_page are the + // same, so it can propagate the condition + $revQuery['joins']['temp_rev_user'][1] = + "temp_rev_user.revactor_rev = rev_id AND revactor_page = rev_page"; + + // The validator ensures this value, if present, is one of the expected values + switch ( $params['filter'] ) { + case 'bot': + // TODO: per T231599, it is possible we will need a STRAIGHT JOIN HERE. + $revQuery['tables']['user_groups'] = 'user_groups'; + $revQuery['joins']['user_groups'] = [ + 'JOIN', + [ + 'actor_rev_user.actor_user = ug_user', + 'ug_group' => $this->permissionManager->getGroupsWithPermission( 'bot' ), + 'ug_expiry IS NULL OR ug_expiry >= ' . $dbr->addQuotes( $dbr->timestamp() ) + ] + ]; + $cond[] = $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER ) . " = 0"; + break; + + case 'anonymous': + $cond[] = "actor_user IS NULL"; + $cond[] = $dbr->bitAnd( 'rev_deleted', RevisionRecord::DELETED_USER ) . " = 0"; + break; + + case 'reverted': + if ( !$tagIds ) { + return false; + } + $cond[] = 'EXISTS(' . $dbr->selectSQLText( + 'change_tag', + 1, + [ 'ct_rev_id = rev_id', 'ct_tag_id' => $tagIds ], + __METHOD__ + ) . ')'; + break; + } + } + + if ( $relativeRevId ) { + $op = $params['older_than'] ? '<' : '>'; + $sort = $params['older_than'] ? 'DESC' : 'ASC'; + $cond[] = "rev_timestamp $op $ts OR " . + "(rev_timestamp = $ts AND rev_id $op {$relativeRevId})"; + $orderBy = "rev_timestamp $sort, rev_id $sort"; + } else { + $orderBy = "rev_timestamp DESC, rev_id DESC"; + } + + // Select one more than the return limit, to learn if there are additional revisions. + $limit = self::REVISIONS_RETURN_LIMIT + 1; + + $res = $dbr->select( + $revQuery['tables'], + $revQuery['fields'], + $cond, + __METHOD__, + [ + 'ORDER BY' => $orderBy, + 'LIMIT' => $limit, + ], + $revQuery['joins'] + ); + + return $res; + } + + /** + * @param IResultWrapper $res database results to process + * @param Title $titleObj title object identifying the page to load history for + * @param array $params request parameters + * @return array response data + */ + private function processDbResults( $res, $titleObj, $params ) { + $revisions = []; + + $sizes = []; + foreach ( $res as $row ) { + $rev = $this->revisionStore->newRevisionFromRow( + $row, + IDBAccessObject::READ_NORMAL, + $titleObj + ); + if ( !$revisions ) { + $firstRevId = $row->rev_id; + } + $lastRevId = $row->rev_id; + + $revision = [ + 'id' => $rev->getId(), + 'timestamp' => wfTimestamp( TS_ISO_8601, $rev->getTimestamp() ), + 'size' => $rev->getSize(), + ]; + + // Remember revision sizes and parent ids for calculating deltas. If a revision's + // parent id is unknown, we will be unable to supply the delta for that revision. + $sizes[$rev->getId()] = $rev->getSize(); + $parentId = $rev->getParentId(); + if ( $parentId ) { + $revision['parent_id'] = $parentId; + } + + $comment = $rev->getComment(); + if ( $comment ) { + $revision['comment'] = $comment->text; + } + + $user = $rev->getUser(); + if ( $user ) { + $revision['user'] = [ + 'name' => $user->getName(), + 'id' => $user->getId(), + ]; + } + + $revisions[] = $revision; + + // Break manually at the return limit. We may have more results than we can return. + if ( count( $revisions ) == self::REVISIONS_RETURN_LIMIT ) { + break; + } + } + + // Request any parent sizes that we do not already know, then calculate deltas + $unknownSizes = []; + foreach ( $revisions as $revision ) { + if ( isset( $revision['parent_id'] ) && !isset( $sizes[$revision['parent_id']] ) ) { + $unknownSizes[] = $revision['parent_id']; + } + } + if ( $unknownSizes ) { + $sizes += $this->revisionStore->getRevisionSizes( $unknownSizes ); + } + foreach ( $revisions as &$revision ) { + if ( isset( $revision['parent_id'] ) ) { + if ( isset( $sizes[$revision['parent_id']] ) ) { + $revision['delta'] = $revision['size'] - $sizes[$revision['parent_id']]; + } + + // We only remembered this for delta calculations. We do not want to return it. + unset( $revision['parent_id'] ); + } + } + + if ( $params['newer_than'] ) { + $revisions = array_reverse( $revisions ); + $temp = $lastRevId; + $lastRevId = $firstRevId; + $firstRevId = $temp; + } + + $response = [ + 'revisions' => $revisions + ]; + + // Omit newer/older if there are no additional corresponding revisions. + // This facilitates clients doing "paging" style api operations. + if ( $params['newer_than'] || $res->numRows() > self::REVISIONS_RETURN_LIMIT ) { + $older = $lastRevId; + } + if ( $params['older_than'] || + ( $params['newer_than'] && $res->numRows() > self::REVISIONS_RETURN_LIMIT ) + ) { + $newer = $firstRevId; + } + + $wr = new \WebRequest(); + $urlParts = wfParseUrl( $wr->getFullRequestUrl() ); + if ( $urlParts ) { + $queryParts = wfCgiToArray( $urlParts['query'] ); + unset( $urlParts['query'] ); + unset( $queryParts['older_than'] ); + unset( $queryParts['newer_than'] ); + + $uri = Uri::fromParts( $urlParts ); + $response['latest'] = Uri::withQueryValues( $uri, $queryParts )->__toString(); + if ( isset( $older ) ) { + $response['older'] = Uri::withQueryValues( + $uri, + $queryParts + [ 'older_than' => $older ] + )->__toString(); + } + if ( isset( $newer ) ) { + $response[' newer'] = Uri::withQueryValues( + $uri, + $queryParts + [ 'newer_than' => $newer ] + )->__toString(); + } + } + + return $response; + } + + public function needsWriteAccess() { + return false; + } + + public function getParamSettings() { + return [ + 'title' => [ + self::PARAM_SOURCE => 'path', + ParamValidator::PARAM_TYPE => 'string', + ParamValidator::PARAM_REQUIRED => true, + ], + 'older_than' => [ + self::PARAM_SOURCE => 'query', + ParamValidator::PARAM_TYPE => 'integer', + ParamValidator::PARAM_REQUIRED => false, + ], + 'newer_than' => [ + self::PARAM_SOURCE => 'query', + ParamValidator::PARAM_TYPE => 'integer', + ParamValidator::PARAM_REQUIRED => false, + ], + 'filter' => [ + self::PARAM_SOURCE => 'query', + ParamValidator::PARAM_TYPE => self::ALLOWED_FILTER_TYPES, + ParamValidator::PARAM_REQUIRED => false, + ], + ]; + } +} diff --git a/includes/Rest/coreRoutes.json b/includes/Rest/coreRoutes.json index 6b440f77593f..3aa7fb1148ff 100644 --- a/includes/Rest/coreRoutes.json +++ b/includes/Rest/coreRoutes.json @@ -2,5 +2,15 @@ { "path": "/user/{name}/hello", "class": "MediaWiki\\Rest\\Handler\\HelloHandler" + }, + { + "path": "/v1/page/{title}/history", + "class": "MediaWiki\\Rest\\Handler\\PageHistoryHandler", + "services": [ + "RevisionStore", + "NameTableStoreFactory", + "PermissionManager", + "DBLoadBalancer" + ] } ] diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 90be8aacd35e..3e0613efc747 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -4254,5 +4254,12 @@ "userlogout-continue": "Do you want to log out?", "rest-prefix-mismatch": "The requested path ($1) was not inside the REST API root path ($2)", "rest-wrong-method": "The request method ($1) was not {{PLURAL:$3|the allowed method for this path|one of the allowed methods for this path}} ($2)", - "rest-no-match": "The requested relative path ($1) did not match any known handler" + "rest-no-match": "The requested relative path ($1) did not match any known handler", + "rest-pagehistory-incompatible-params": "Parameters \"older_than\" and \"newer_than\" cannot both be specified", + "rest-pagehistory-param-range-error": "Revision id must be greater than 0", + "rest-pagehistory-title-nonexistent": "The specified title ($1) does not exist", + "rest-pagehistory-revision-nonexistent": "The specified revision ($1) does not exist for the specified page ($2)", + "rest-pagehistory-revisions-nonexistent": "No revisions were found for the specified page ($1)", + "rest-pagehistory-revisions-nonexistent-with-params": "No revisions were found for the specified page ($1) that match the specified parameters", + "rest-pagehistory-timestamp-error": "Unable to retrieve timestamp for the specified revision ($1)" } diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index a92f46b0208b..a53cb407a1ad 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -4466,5 +4466,12 @@ "userlogout-continue": "Shown if user attempted to log out without a token specified. Probably the user clicked on an old link that hasn't been updated to use the new system. $1 - url that user should click on in order to log out.", "rest-prefix-mismatch": "Error message for REST API debugging, shown if $wgRestPath is incorrect or otherwise not matched. Parameters:\n* $1: The requested path.\n* $2: The configured root path ($wgRestPath).", "rest-wrong-method": "Error message for REST API debugging, shown if the HTTP method is incorrect. Parameters:\n* $1: The received request method.\n* $2: A comma-separated list of allowed methods for this path.\n* $3: The number of items in the list $2", - "rest-no-match": "Error message for REST API debugging, shown if the path has the correct prefix but did not match any registered handler. Parameters:\n* $1: The received request path, relative to $wgRestPath." + "rest-no-match": "Error message for REST API debugging, shown if the path has the correct prefix but did not match any registered handler. Parameters:\n* $1: The received request path, relative to $wgRestPath.", + "rest-pagehistory-incompatible-params": "Error message for REST API debugging, shown if incompatible parameters are specified.", + "rest-pagehistory-param-range-error": "Error message for REST API debugging, shown if a revision id is provided but is out of range.", + "rest-pagehistory-title-nonexistent": "Error message for REST API debugging, shown if the specified title does not exist.", + "rest-pagehistory-revision-nonexistent": "Error message for REST API debugging, shown if the specified revision does not exist.", + "rest-pagehistory-revisions-nonexistent": "Error message for REST API debugging, shown if no revisions exist for the specified page.", + "rest-pagehistory-revisions-nonexistent-with-params": "Error message for REST API debugging, shown if no revisions exist for the specified page, and if params that limit the returned revisions were specified.", + "rest-pagehistory-timestamp-error": "Error message for REST API debugging, shown if an error occurred loading the timestamp for the specified revision." } |