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