Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
51.14% covered (warning)
51.14%
157 / 307
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiQueryRevisions
51.14% covered (warning)
51.14%
157 / 307
40.00% covered (danger)
40.00%
2 / 5
712.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 run
41.47% covered (danger)
41.47%
90 / 217
0.00% covered (danger)
0.00%
0 / 1
1052.26
 getAllowedParams
100.00% covered (success)
100.00%
49 / 49
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23use MediaWiki\CommentFormatter\CommentFormatter;
24use MediaWiki\Content\IContentHandlerFactory;
25use MediaWiki\Content\Renderer\ContentRenderer;
26use MediaWiki\Content\Transform\ContentTransformer;
27use MediaWiki\Page\PageIdentity;
28use MediaWiki\ParamValidator\TypeDef\UserDef;
29use MediaWiki\Revision\RevisionRecord;
30use MediaWiki\Revision\RevisionStore;
31use MediaWiki\Revision\SlotRoleRegistry;
32use MediaWiki\Status\Status;
33use MediaWiki\Storage\NameTableAccessException;
34use MediaWiki\Storage\NameTableStore;
35use MediaWiki\Title\Title;
36use MediaWiki\Title\TitleFormatter;
37use MediaWiki\User\ActorMigration;
38use MediaWiki\User\TempUser\TempUserCreator;
39use MediaWiki\User\UserFactory;
40use Wikimedia\ParamValidator\ParamValidator;
41
42/**
43 * A query action to enumerate revisions of a given page, or show top revisions
44 * of multiple pages. Various pieces of information may be shown - flags,
45 * comments, and the actual wiki markup of the rev. In the enumeration mode,
46 * ranges of revisions may be requested and filtered.
47 *
48 * @ingroup API
49 */
50class ApiQueryRevisions extends ApiQueryRevisionsBase {
51
52    private RevisionStore $revisionStore;
53    private NameTableStore $changeTagDefStore;
54    private ActorMigration $actorMigration;
55    private TitleFormatter $titleFormatter;
56
57    /**
58     * @param ApiQuery $query
59     * @param string $moduleName
60     * @param RevisionStore $revisionStore
61     * @param IContentHandlerFactory $contentHandlerFactory
62     * @param ParserFactory $parserFactory
63     * @param SlotRoleRegistry $slotRoleRegistry
64     * @param NameTableStore $changeTagDefStore
65     * @param ActorMigration $actorMigration
66     * @param ContentRenderer $contentRenderer
67     * @param ContentTransformer $contentTransformer
68     * @param CommentFormatter $commentFormatter
69     * @param TempUserCreator $tempUserCreator
70     * @param UserFactory $userFactory
71     * @param TitleFormatter $titleFormatter
72     */
73    public function __construct(
74        ApiQuery $query,
75        $moduleName,
76        RevisionStore $revisionStore,
77        IContentHandlerFactory $contentHandlerFactory,
78        ParserFactory $parserFactory,
79        SlotRoleRegistry $slotRoleRegistry,
80        NameTableStore $changeTagDefStore,
81        ActorMigration $actorMigration,
82        ContentRenderer $contentRenderer,
83        ContentTransformer $contentTransformer,
84        CommentFormatter $commentFormatter,
85        TempUserCreator $tempUserCreator,
86        UserFactory $userFactory,
87        TitleFormatter $titleFormatter
88    ) {
89        parent::__construct(
90            $query,
91            $moduleName,
92            'rv',
93            $revisionStore,
94            $contentHandlerFactory,
95            $parserFactory,
96            $slotRoleRegistry,
97            $contentRenderer,
98            $contentTransformer,
99            $commentFormatter,
100            $tempUserCreator,
101            $userFactory
102        );
103        $this->revisionStore = $revisionStore;
104        $this->changeTagDefStore = $changeTagDefStore;
105        $this->actorMigration = $actorMigration;
106        $this->titleFormatter = $titleFormatter;
107    }
108
109    protected function run( ApiPageSet $resultPageSet = null ) {
110        $params = $this->extractRequestParams( false );
111
112        // If any of those parameters are used, work in 'enumeration' mode.
113        // Enum mode can only be used when exactly one page is provided.
114        // Enumerating revisions on multiple pages make it extremely
115        // difficult to manage continuations and require additional SQL indexes
116        $enumRevMode = ( $params['user'] !== null || $params['excludeuser'] !== null ||
117            $params['limit'] !== null || $params['startid'] !== null ||
118            $params['endid'] !== null || $params['dir'] === 'newer' ||
119            $params['start'] !== null || $params['end'] !== null );
120
121        $pageSet = $this->getPageSet();
122        $pageCount = $pageSet->getGoodTitleCount();
123        $revCount = $pageSet->getRevisionCount();
124
125        // Optimization -- nothing to do
126        if ( $revCount === 0 && $pageCount === 0 ) {
127            // Nothing to do
128            return;
129        }
130        if ( $revCount > 0 && count( $pageSet->getLiveRevisionIDs() ) === 0 ) {
131            // We're in revisions mode but all given revisions are deleted
132            return;
133        }
134
135        if ( $revCount > 0 && $enumRevMode ) {
136            $this->dieWithError(
137                [ 'apierror-revisions-norevids', $this->getModulePrefix() ], 'invalidparammix'
138            );
139        }
140
141        if ( $pageCount > 1 && $enumRevMode ) {
142            $this->dieWithError(
143                [ 'apierror-revisions-singlepage', $this->getModulePrefix() ], 'invalidparammix'
144            );
145        }
146
147        // In non-enum mode, rvlimit can't be directly used. Use the maximum
148        // allowed value.
149        if ( !$enumRevMode ) {
150            $this->setParsedLimit = false;
151            $params['limit'] = 'max';
152        }
153
154        $db = $this->getDB();
155
156        $idField = 'rev_id';
157        $tsField = 'rev_timestamp';
158        $pageField = 'rev_page';
159
160        $ignoreIndex = [
161            // T224017: `rev_timestamp` is never the correct index to use for this module, but
162            // MariaDB sometimes insists on trying to use it anyway. Tell it not to.
163            // Last checked with MariaDB 10.4.13
164            'revision' => 'rev_timestamp',
165        ];
166        $useIndex = [];
167        if ( $resultPageSet === null ) {
168            $this->parseParameters( $params );
169            $queryBuilder = $this->revisionStore->newSelectQueryBuilder( $db )
170                ->joinComment()
171                ->joinPage();
172            if ( $this->fld_user ) {
173                $queryBuilder->joinUser();
174            }
175            $this->getQueryBuilder()->merge( $queryBuilder );
176        } else {
177            $this->limit = $this->getParameter( 'limit' ) ?: 10;
178            // Always join 'page' so orphaned revisions are filtered out
179            $this->addTables( [ 'revision', 'page' ] );
180            $this->addJoinConds(
181                [ 'page' => [ 'JOIN', [ 'page_id = rev_page' ] ] ]
182            );
183            $this->addFields( [
184                'rev_id' => $idField, 'rev_timestamp' => $tsField, 'rev_page' => $pageField
185            ] );
186        }
187
188        if ( $this->fld_tags ) {
189            $this->addFields( [ 'ts_tags' => ChangeTags::makeTagSummarySubquery( 'revision' ) ] );
190        }
191
192        if ( $params['tag'] !== null ) {
193            $this->addTables( 'change_tag' );
194            $this->addJoinConds(
195                [ 'change_tag' => [ 'JOIN', [ 'rev_id=ct_rev_id' ] ] ]
196            );
197            try {
198                $this->addWhereFld( 'ct_tag_id', $this->changeTagDefStore->getId( $params['tag'] ) );
199            } catch ( NameTableAccessException $exception ) {
200                // Return nothing.
201                $this->addWhere( '1=0' );
202            }
203        }
204
205        if ( $resultPageSet === null && $this->fetchContent ) {
206            // For each page we will request, the user must have read rights for that page
207            $status = Status::newGood();
208
209            /** @var PageIdentity $pageIdentity */
210            foreach ( $pageSet->getGoodPages() as $pageIdentity ) {
211                if ( !$this->getAuthority()->authorizeRead( 'read', $pageIdentity ) ) {
212                    $status->fatal( ApiMessage::create(
213                        [
214                            'apierror-cannotviewtitle',
215                            wfEscapeWikiText( $this->titleFormatter->getPrefixedText( $pageIdentity ) ),
216                        ],
217                        'accessdenied'
218                    ) );
219                }
220            }
221            if ( !$status->isGood() ) {
222                $this->dieStatus( $status );
223            }
224        }
225
226        if ( $enumRevMode ) {
227            // Indexes targeted:
228            //  page_timestamp if we don't have rvuser
229            //  page_actor_timestamp (on revision_actor_temp) if we have rvuser in READ_NEW mode
230            //  page_user_timestamp if we have a logged-in rvuser
231            //  page_timestamp or usertext_timestamp if we have an IP rvuser
232
233            // This is mostly to prevent parameter errors (and optimize SQL?)
234            $this->requireMaxOneParameter( $params, 'startid', 'start' );
235            $this->requireMaxOneParameter( $params, 'endid', 'end' );
236            $this->requireMaxOneParameter( $params, 'user', 'excludeuser' );
237
238            if ( $params['continue'] !== null ) {
239                $cont = $this->parseContinueParamOrDie( $params['continue'], [ 'timestamp', 'int' ] );
240                $op = ( $params['dir'] === 'newer' ? '>=' : '<=' );
241                $continueTimestamp = $db->timestamp( $cont[0] );
242                $continueId = (int)$cont[1];
243                $this->addWhere( $db->buildComparison( $op, [
244                    $tsField => $continueTimestamp,
245                    $idField => $continueId,
246                ] ) );
247            }
248
249            // Convert startid/endid to timestamps (T163532)
250            $revids = [];
251            if ( $params['startid'] !== null ) {
252                $revids[] = (int)$params['startid'];
253            }
254            if ( $params['endid'] !== null ) {
255                $revids[] = (int)$params['endid'];
256            }
257            if ( $revids ) {
258                $db = $this->getDB();
259                $uqb = $db->newUnionQueryBuilder();
260                $uqb->add(
261                    $db->newSelectQueryBuilder()
262                        ->select( [ 'id' => 'rev_id', 'ts' => 'rev_timestamp' ] )
263                        ->from( 'revision' )
264                        ->where( [ 'rev_id' => $revids ] )
265                );
266                $uqb->add(
267                    $db->newSelectQueryBuilder()
268                        ->select( [ 'id' => 'ar_rev_id', 'ts' => 'ar_timestamp' ] )
269                        ->from( 'archive' )
270                        ->where( [ 'ar_rev_id' => $revids ] )
271                );
272                $res = $uqb->caller( __METHOD__ )->fetchResultSet();
273                foreach ( $res as $row ) {
274                    if ( (int)$row->id === (int)$params['startid'] ) {
275                        $params['start'] = $row->ts;
276                    }
277                    if ( (int)$row->id === (int)$params['endid'] ) {
278                        $params['end'] = $row->ts;
279                    }
280                }
281                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
282                if ( $params['startid'] !== null && $params['start'] === null ) {
283                    $p = $this->encodeParamName( 'startid' );
284                    $this->dieWithError( [ 'apierror-revisions-badid', $p ], "badid_$p" );
285                }
286                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
287                if ( $params['endid'] !== null && $params['end'] === null ) {
288                    $p = $this->encodeParamName( 'endid' );
289                    $this->dieWithError( [ 'apierror-revisions-badid', $p ], "badid_$p" );
290                }
291
292                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
293                if ( $params['start'] !== null ) {
294                    $op = ( $params['dir'] === 'newer' ? '>=' : '<=' );
295                    // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
296                    $ts = $db->timestampOrNull( $params['start'] );
297                    if ( $params['startid'] !== null ) {
298                        $this->addWhere( $db->buildComparison( $op, [
299                            $tsField => $ts,
300                            $idField => (int)$params['startid'],
301                        ] ) );
302                    } else {
303                        $this->addWhere( $db->buildComparison( $op, [ $tsField => $ts ] ) );
304                    }
305                }
306                // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
307                if ( $params['end'] !== null ) {
308                    $op = ( $params['dir'] === 'newer' ? '<=' : '>=' ); // Yes, opposite of the above
309                    // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset False positive
310                    $ts = $db->timestampOrNull( $params['end'] );
311                    if ( $params['endid'] !== null ) {
312                        $this->addWhere( $db->buildComparison( $op, [
313                            $tsField => $ts,
314                            $idField => (int)$params['endid'],
315                        ] ) );
316                    } else {
317                        $this->addWhere( $db->buildComparison( $op, [ $tsField => $ts ] ) );
318                    }
319                }
320            } else {
321                $this->addTimestampWhereRange( $tsField, $params['dir'],
322                    $params['start'], $params['end'] );
323            }
324
325            $sort = ( $params['dir'] === 'newer' ? '' : 'DESC' );
326            $this->addOption( 'ORDER BY', [ "rev_timestamp $sort", "rev_id $sort" ] );
327
328            // There is only one ID, use it
329            $ids = array_keys( $pageSet->getGoodPages() );
330            $this->addWhereFld( $pageField, reset( $ids ) );
331
332            if ( $params['user'] !== null ) {
333                $actorQuery = $this->actorMigration->getWhere( $db, 'rev_user', $params['user'] );
334                $this->addTables( $actorQuery['tables'] );
335                $this->addJoinConds( $actorQuery['joins'] );
336                $this->addWhere( $actorQuery['conds'] );
337            } elseif ( $params['excludeuser'] !== null ) {
338                $actorQuery = $this->actorMigration->getWhere( $db, 'rev_user', $params['excludeuser'] );
339                $this->addTables( $actorQuery['tables'] );
340                $this->addJoinConds( $actorQuery['joins'] );
341                $this->addWhere( 'NOT(' . $actorQuery['conds'] . ')' );
342            } else {
343                // T258480: MariaDB ends up using rev_page_actor_timestamp in some cases here.
344                // Last checked with MariaDB 10.4.13
345                // Unless we are filtering by user (see above), we always want to use the
346                // "history" index on the revision table, namely page_timestamp.
347                $useIndex['revision'] = 'rev_page_timestamp';
348            }
349
350            if ( $params['user'] !== null || $params['excludeuser'] !== null ) {
351                // Paranoia: avoid brute force searches (T19342)
352                if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
353                    $bitmask = RevisionRecord::DELETED_USER;
354                } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
355                    $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED;
356                } else {
357                    $bitmask = 0;
358                }
359                if ( $bitmask ) {
360                    $this->addWhere( $db->bitAnd( 'rev_deleted', $bitmask ) . " != $bitmask" );
361                }
362            }
363        } elseif ( $revCount > 0 ) {
364            // Always targets the PRIMARY index
365
366            $revs = $pageSet->getLiveRevisionIDs();
367
368            // Get all revision IDs
369            $this->addWhereFld( 'rev_id', array_keys( $revs ) );
370
371            if ( $params['continue'] !== null ) {
372                $this->addWhere( $db->buildComparison( '>=', [
373                    'rev_id' => (int)$params['continue']
374                ] ) );
375            }
376            $this->addOption( 'ORDER BY', 'rev_id' );
377        } elseif ( $pageCount > 0 ) {
378            // Always targets the rev_page_id index
379
380            $pageids = array_keys( $pageSet->getGoodPages() );
381
382            // When working in multi-page non-enumeration mode,
383            // limit to the latest revision only
384            $this->addWhere( 'page_latest=rev_id' );
385
386            // Get all page IDs
387            $this->addWhereFld( 'page_id', $pageids );
388            // Every time someone relies on equality propagation, god kills a kitten :)
389            $this->addWhereFld( 'rev_page', $pageids );
390
391            if ( $params['continue'] !== null ) {
392                $cont = $this->parseContinueParamOrDie( $params['continue'], [ 'int', 'int' ] );
393                $this->addWhere( $db->buildComparison( '>=', [
394                    'rev_page' => $cont[0],
395                    'rev_id' => $cont[1],
396                ] ) );
397            }
398            $this->addOption( 'ORDER BY', [
399                'rev_page',
400                'rev_id'
401            ] );
402        } else {
403            ApiBase::dieDebug( __METHOD__, 'param validation?' );
404        }
405
406        $this->addOption( 'LIMIT', $this->limit + 1 );
407
408        $this->addOption( 'IGNORE INDEX', $ignoreIndex );
409
410        if ( $useIndex ) {
411            $this->addOption( 'USE INDEX', $useIndex );
412        }
413
414        $count = 0;
415        $generated = [];
416        $hookData = [];
417        $res = $this->select( __METHOD__, [], $hookData );
418
419        foreach ( $res as $row ) {
420            if ( ++$count > $this->limit ) {
421                // We've reached the one extra which shows that there are
422                // additional pages to be had. Stop here...
423                if ( $enumRevMode ) {
424                    $this->setContinueEnumParameter( 'continue',
425                        $row->rev_timestamp . '|' . (int)$row->rev_id );
426                } elseif ( $revCount > 0 ) {
427                    $this->setContinueEnumParameter( 'continue', (int)$row->rev_id );
428                } else {
429                    $this->setContinueEnumParameter( 'continue', (int)$row->rev_page .
430                        '|' . (int)$row->rev_id );
431                }
432                break;
433            }
434
435            if ( $resultPageSet !== null ) {
436                $generated[] = $row->rev_id;
437            } else {
438                $revision = $this->revisionStore->newRevisionFromRow( $row, 0, Title::newFromRow( $row ) );
439                $rev = $this->extractRevisionInfo( $revision, $row );
440                $fit = $this->processRow( $row, $rev, $hookData ) &&
441                    $this->addPageSubItem( $row->rev_page, $rev, 'rev' );
442                if ( !$fit ) {
443                    if ( $enumRevMode ) {
444                        $this->setContinueEnumParameter( 'continue',
445                            $row->rev_timestamp . '|' . (int)$row->rev_id );
446                    } elseif ( $revCount > 0 ) {
447                        $this->setContinueEnumParameter( 'continue', (int)$row->rev_id );
448                    } else {
449                        $this->setContinueEnumParameter( 'continue', (int)$row->rev_page .
450                            '|' . (int)$row->rev_id );
451                    }
452                    break;
453                }
454            }
455        }
456
457        if ( $resultPageSet !== null ) {
458            $resultPageSet->populateFromRevisionIDs( $generated );
459        }
460    }
461
462    public function getAllowedParams() {
463        $ret = parent::getAllowedParams() + [
464            'startid' => [
465                ParamValidator::PARAM_TYPE => 'integer',
466                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
467            ],
468            'endid' => [
469                ParamValidator::PARAM_TYPE => 'integer',
470                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
471            ],
472            'start' => [
473                ParamValidator::PARAM_TYPE => 'timestamp',
474                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
475            ],
476            'end' => [
477                ParamValidator::PARAM_TYPE => 'timestamp',
478                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
479            ],
480            'dir' => [
481                ParamValidator::PARAM_DEFAULT => 'older',
482                ParamValidator::PARAM_TYPE => [
483                    'newer',
484                    'older'
485                ],
486                ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
487                ApiBase::PARAM_HELP_MSG_PER_VALUE => [
488                    'newer' => 'api-help-paramvalue-direction-newer',
489                    'older' => 'api-help-paramvalue-direction-older',
490                ],
491                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
492            ],
493            'user' => [
494                ParamValidator::PARAM_TYPE => 'user',
495                UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name', 'ip', 'temp', 'id', 'interwiki' ],
496                UserDef::PARAM_RETURN_OBJECT => true,
497                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
498            ],
499            'excludeuser' => [
500                ParamValidator::PARAM_TYPE => 'user',
501                UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name', 'ip', 'temp', 'id', 'interwiki' ],
502                UserDef::PARAM_RETURN_OBJECT => true,
503                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'singlepageonly' ] ],
504            ],
505            'tag' => null,
506            'continue' => [
507                ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
508            ],
509        ];
510
511        $ret['limit'][ApiBase::PARAM_HELP_MSG_INFO] = [ [ 'singlepageonly' ] ];
512
513        return $ret;
514    }
515
516    protected function getExamplesMessages() {
517        $title = Title::newMainPage()->getPrefixedText();
518        $mp = rawurlencode( $title );
519
520        return [
521            "action=query&prop=revisions&titles=API|{$mp}&" .
522                'rvslots=*&rvprop=timestamp|user|comment|content'
523                => 'apihelp-query+revisions-example-content',
524            "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
525                'rvprop=timestamp|user|comment'
526                => 'apihelp-query+revisions-example-last5',
527            "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
528                'rvprop=timestamp|user|comment&rvdir=newer'
529                => 'apihelp-query+revisions-example-first5',
530            "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
531                'rvprop=timestamp|user|comment&rvdir=newer&rvstart=2006-05-01T00:00:00Z'
532                => 'apihelp-query+revisions-example-first5-after',
533            "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
534                'rvprop=timestamp|user|comment&rvexcludeuser=127.0.0.1'
535                => 'apihelp-query+revisions-example-first5-not-localhost',
536            "action=query&prop=revisions&titles={$mp}&rvlimit=5&" .
537                'rvprop=timestamp|user|comment&rvuser=MediaWiki%20default'
538                => 'apihelp-query+revisions-example-first5-user',
539        ];
540    }
541
542    public function getHelpUrls() {
543        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Revisions';
544    }
545}