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