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