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