Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.98% covered (warning)
57.98%
178 / 307
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiQueryAllDeletedRevisions
58.17% covered (warning)
58.17%
178 / 306
20.00% covered (danger)
20.00%
1 / 5
587.22
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
48.20% covered (danger)
48.20%
107 / 222
0.00% covered (danger)
0.00%
0 / 1
923.71
 getAllowedParams
89.83% covered (warning)
89.83%
53 / 59
0.00% covered (danger)
0.00%
0 / 1
2.00
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 6
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 © 2014 Wikimedia Foundation and contributors
4 *
5 * Heavily based on ApiQueryDeletedrevs,
6 * Copyright © 2007 Roan Kattouw <roan.kattouw@gmail.com>
7 *
8 * @license GPL-2.0-or-later
9 * @file
10 */
11
12namespace MediaWiki\Api;
13
14use MediaWiki\ChangeTags\ChangeTagsStore;
15use MediaWiki\CommentFormatter\CommentFormatter;
16use MediaWiki\Content\IContentHandlerFactory;
17use MediaWiki\Content\Renderer\ContentRenderer;
18use MediaWiki\Content\Transform\ContentTransformer;
19use MediaWiki\MainConfigNames;
20use MediaWiki\ParamValidator\TypeDef\UserDef;
21use MediaWiki\Parser\ParserFactory;
22use MediaWiki\Revision\RevisionRecord;
23use MediaWiki\Revision\RevisionStore;
24use MediaWiki\Revision\SlotRoleRegistry;
25use MediaWiki\Storage\NameTableAccessException;
26use MediaWiki\Storage\NameTableStore;
27use MediaWiki\Title\NamespaceInfo;
28use MediaWiki\Title\Title;
29use MediaWiki\User\TempUser\TempUserCreator;
30use MediaWiki\User\UserFactory;
31use Wikimedia\ParamValidator\ParamValidator;
32use Wikimedia\Rdbms\IExpression;
33use Wikimedia\Rdbms\LikeValue;
34
35/**
36 * Query module to enumerate all deleted revisions.
37 *
38 * @ingroup API
39 */
40class ApiQueryAllDeletedRevisions extends ApiQueryRevisionsBase {
41
42    private RevisionStore $revisionStore;
43    private NameTableStore $changeTagDefStore;
44    private ChangeTagsStore $changeTagsStore;
45    private NamespaceInfo $namespaceInfo;
46
47    public function __construct(
48        ApiQuery $query,
49        string $moduleName,
50        RevisionStore $revisionStore,
51        IContentHandlerFactory $contentHandlerFactory,
52        ParserFactory $parserFactory,
53        SlotRoleRegistry $slotRoleRegistry,
54        NameTableStore $changeTagDefStore,
55        ChangeTagsStore $changeTagsStore,
56        NamespaceInfo $namespaceInfo,
57        ContentRenderer $contentRenderer,
58        ContentTransformer $contentTransformer,
59        CommentFormatter $commentFormatter,
60        TempUserCreator $tempUserCreator,
61        UserFactory $userFactory
62    ) {
63        parent::__construct(
64            $query,
65            $moduleName,
66            'adr',
67            $revisionStore,
68            $contentHandlerFactory,
69            $parserFactory,
70            $slotRoleRegistry,
71            $contentRenderer,
72            $contentTransformer,
73            $commentFormatter,
74            $tempUserCreator,
75            $userFactory
76        );
77        $this->revisionStore = $revisionStore;
78        $this->changeTagDefStore = $changeTagDefStore;
79        $this->changeTagsStore = $changeTagsStore;
80        $this->namespaceInfo = $namespaceInfo;
81    }
82
83    /**
84     * @param ApiPageSet|null $resultPageSet
85     * @return void
86     */
87    protected function run( ?ApiPageSet $resultPageSet = null ) {
88        $db = $this->getDB();
89        $params = $this->extractRequestParams( false );
90
91        $result = $this->getResult();
92
93        // If the user wants no namespaces, they get no pages.
94        if ( $params['namespace'] === [] ) {
95            if ( $resultPageSet === null ) {
96                $result->addValue( 'query', $this->getModuleName(), [] );
97            }
98            return;
99        }
100
101        // This module operates in two modes:
102        // 'user': List deleted revs by a certain user
103        // 'all': List all deleted revs in NS
104        $mode = 'all';
105        if ( $params['user'] !== null ) {
106            $mode = 'user';
107        }
108
109        if ( $mode == 'user' ) {
110            foreach ( [ 'from', 'to', 'prefix', 'excludeuser' ] as $param ) {
111                if ( $params[$param] !== null ) {
112                    $p = $this->getModulePrefix();
113                    $this->dieWithError(
114                        [ 'apierror-invalidparammix-cannotusewith', $p . $param, "{$p}user" ],
115                        'invalidparammix'
116                    );
117                }
118            }
119        } else {
120            foreach ( [ 'start', 'end' ] as $param ) {
121                if ( $params[$param] !== null ) {
122                    $p = $this->getModulePrefix();
123                    $this->dieWithError(
124                        [ 'apierror-invalidparammix-mustusewith', $p . $param, "{$p}user" ],
125                        'invalidparammix'
126                    );
127                }
128            }
129        }
130
131        // If we're generating titles only, we can use DISTINCT for a better
132        // query. But we can't do that in 'user' mode (wrong index), and we can
133        // only do it when sorting ASC (because MySQL apparently can't use an
134        // index backwards for grouping even though it can for ORDER BY, WTF?)
135        $dir = $params['dir'];
136        $optimizeGenerateTitles = false;
137        if ( $mode === 'all' && $params['generatetitles'] && $resultPageSet !== null ) {
138            if ( $dir === 'newer' ) {
139                $optimizeGenerateTitles = true;
140            } else {
141                $p = $this->getModulePrefix();
142                $this->addWarning( [ 'apiwarn-alldeletedrevisions-performance', $p ], 'performance' );
143            }
144        }
145
146        if ( $resultPageSet === null ) {
147            $this->parseParameters( $params );
148            $arQuery = $this->revisionStore->getArchiveQueryInfo();
149            $this->addTables( $arQuery['tables'] );
150            $this->addJoinConds( $arQuery['joins'] );
151            $this->addFields( $arQuery['fields'] );
152            $this->addFields( [ 'ar_title', 'ar_namespace' ] );
153        } else {
154            $this->limit = $this->getParameter( 'limit' ) ?: 10;
155            $this->addTables( 'archive' );
156            $this->addFields( [ 'ar_title', 'ar_namespace' ] );
157            if ( $optimizeGenerateTitles ) {
158                $this->addOption( 'DISTINCT' );
159            } else {
160                $this->addFields( [ 'ar_timestamp', 'ar_rev_id', 'ar_id' ] );
161            }
162            if ( $params['user'] !== null || $params['excludeuser'] !== null ) {
163                $this->addTables( 'actor' );
164                $this->addJoinConds( [ 'actor' => 'actor_id=ar_actor' ] );
165            }
166        }
167
168        if ( $this->fld_tags ) {
169            $this->addFields( [
170                'ts_tags' => $this->changeTagsStore->makeTagSummarySubquery( 'archive' )
171            ] );
172        }
173
174        if ( $params['tag'] !== null ) {
175            $this->addTables( 'change_tag' );
176            $this->addJoinConds(
177                [ 'change_tag' => [ 'JOIN', [ 'ar_rev_id=ct_rev_id' ] ] ]
178            );
179            try {
180                $this->addWhereFld( 'ct_tag_id', $this->changeTagDefStore->getId( $params['tag'] ) );
181            } catch ( NameTableAccessException ) {
182                // Return nothing.
183                $this->addWhere( '1=0' );
184            }
185        }
186
187        // This means stricter restrictions
188        if ( ( $this->fld_comment || $this->fld_parsedcomment ) &&
189            !$this->getAuthority()->isAllowed( 'deletedhistory' )
190        ) {
191            $this->dieWithError( 'apierror-cantview-deleted-comment', 'permissiondenied' );
192        }
193        if ( $this->fetchContent &&
194            !$this->getAuthority()->isAllowedAny( 'deletedtext', 'undelete' )
195        ) {
196            $this->dieWithError( 'apierror-cantview-deleted-revision-content', 'permissiondenied' );
197        }
198
199        $miser_ns = null;
200
201        if ( $mode == 'all' ) {
202            $namespaces = $params['namespace'] ?? $this->namespaceInfo->getValidNamespaces();
203            $this->addWhereFld( 'ar_namespace', $namespaces );
204
205            // For from/to/prefix, we have to consider the potential
206            // transformations of the title in all specified namespaces.
207            // Generally there will be only one transformation, but wikis with
208            // some namespaces case-sensitive could have two.
209            if ( $params['from'] !== null || $params['to'] !== null ) {
210                $isDirNewer = ( $dir === 'newer' );
211                $after = ( $isDirNewer ? '>=' : '<=' );
212                $before = ( $isDirNewer ? '<=' : '>=' );
213                $titleParts = [];
214                foreach ( $namespaces as $ns ) {
215                    if ( $params['from'] !== null ) {
216                        $fromTitlePart = $this->titlePartToKey( $params['from'], $ns );
217                    } else {
218                        $fromTitlePart = '';
219                    }
220                    if ( $params['to'] !== null ) {
221                        $toTitlePart = $this->titlePartToKey( $params['to'], $ns );
222                    } else {
223                        $toTitlePart = '';
224                    }
225                    $titleParts[$fromTitlePart . '|' . $toTitlePart][] = $ns;
226                }
227                if ( count( $titleParts ) === 1 ) {
228                    [ $fromTitlePart, $toTitlePart, ] = explode( '|', key( $titleParts ), 2 );
229                    if ( $fromTitlePart !== '' ) {
230                        $this->addWhere( $db->expr( 'ar_title', $after, $fromTitlePart ) );
231                    }
232                    if ( $toTitlePart !== '' ) {
233                        $this->addWhere( $db->expr( 'ar_title', $before, $toTitlePart ) );
234                    }
235                } else {
236                    $where = [];
237                    foreach ( $titleParts as $titlePart => $ns ) {
238                        [ $fromTitlePart, $toTitlePart, ] = explode( '|', $titlePart, 2 );
239                        $expr = $db->expr( 'ar_namespace', '=', $ns );
240                        if ( $fromTitlePart !== '' ) {
241                            $expr = $expr->and( 'ar_title', $after, $fromTitlePart );
242                        }
243                        if ( $toTitlePart !== '' ) {
244                            $expr = $expr->and( 'ar_title', $before, $toTitlePart );
245                        }
246                        $where[] = $expr;
247                    }
248                    $this->addWhere( $db->orExpr( $where ) );
249                }
250            }
251
252            if ( isset( $params['prefix'] ) ) {
253                $titleParts = [];
254                foreach ( $namespaces as $ns ) {
255                    $prefixTitlePart = $this->titlePartToKey( $params['prefix'], $ns );
256                    $titleParts[$prefixTitlePart][] = $ns;
257                }
258                if ( count( $titleParts ) === 1 ) {
259                    $prefixTitlePart = key( $titleParts );
260                    $this->addWhere( $db->expr( 'ar_title', IExpression::LIKE,
261                        new LikeValue( $prefixTitlePart, $db->anyString() )
262                    ) );
263                } else {
264                    $where = [];
265                    foreach ( $titleParts as $prefixTitlePart => $ns ) {
266                        $where[] = $db->expr( 'ar_namespace', '=', $ns )
267                            ->and( 'ar_title', IExpression::LIKE,
268                                new LikeValue( $prefixTitlePart, $db->anyString() ) );
269                    }
270                    $this->addWhere( $db->orExpr( $where ) );
271                }
272            }
273        } else {
274            if ( $this->getConfig()->get( MainConfigNames::MiserMode ) ) {
275                $miser_ns = $params['namespace'];
276            } else {
277                $this->addWhereFld( 'ar_namespace', $params['namespace'] );
278            }
279            $this->addTimestampWhereRange( 'ar_timestamp', $dir, $params['start'], $params['end'] );
280        }
281
282        if ( $params['user'] !== null ) {
283            // We could get the actor ID from the ActorStore, but it's probably
284            // uncached at this point, and the non-generator case needs an actor
285            // join anyway so adding this join here is normally free. This should
286            // use the ar_actor_timestamp index.
287            $this->addWhereFld( 'actor_name', $params['user'] );
288        } elseif ( $params['excludeuser'] !== null ) {
289            $this->addWhere( $db->expr( 'actor_name', '!=', $params['excludeuser'] ) );
290        }
291
292        if ( $params['user'] !== null || $params['excludeuser'] !== null ) {
293            // Paranoia: avoid brute force searches (T19342)
294            if ( !$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
295                $bitmask = RevisionRecord::DELETED_USER;
296            } elseif ( !$this->getAuthority()->isAllowedAny( 'suppressrevision', 'viewsuppressed' ) ) {
297                $bitmask = RevisionRecord::DELETED_USER | RevisionRecord::DELETED_RESTRICTED;
298            } else {
299                $bitmask = 0;
300            }
301            if ( $bitmask ) {
302                $this->addWhere( $db->bitAnd( 'ar_deleted', $bitmask ) . " != $bitmask" );
303            }
304        }
305
306        if ( $params['continue'] !== null ) {
307            $op = ( $dir == 'newer' ? '>=' : '<=' );
308            if ( $optimizeGenerateTitles ) {
309                $cont = $this->parseContinueParamOrDie( $params['continue'], [ 'int', 'string' ] );
310                $this->addWhere( $db->buildComparison( $op, [
311                    'ar_namespace' => $cont[0],
312                    'ar_title' => $cont[1],
313                ] ) );
314            } elseif ( $mode == 'all' ) {
315                $cont = $this->parseContinueParamOrDie( $params['continue'], [ 'int', 'string', 'timestamp', 'int' ] );
316                $this->addWhere( $db->buildComparison( $op, [
317                    'ar_namespace' => $cont[0],
318                    'ar_title' => $cont[1],
319                    'ar_timestamp' => $db->timestamp( $cont[2] ),
320                    'ar_id' => $cont[3],
321                ] ) );
322            } else {
323                $cont = $this->parseContinueParamOrDie( $params['continue'], [ 'timestamp', 'int' ] );
324                $this->addWhere( $db->buildComparison( $op, [
325                    'ar_timestamp' => $db->timestamp( $cont[0] ),
326                    'ar_id' => $cont[1],
327                ] ) );
328            }
329        }
330
331        $this->addOption( 'LIMIT', $this->limit + 1 );
332
333        $sort = ( $dir == 'newer' ? '' : ' DESC' );
334        $orderby = [];
335        if ( $optimizeGenerateTitles ) {
336            // Targeting index ar_name_title_timestamp
337            if ( $params['namespace'] === null || count( array_unique( $params['namespace'] ) ) > 1 ) {
338                $orderby[] = "ar_namespace $sort";
339            }
340            $orderby[] = "ar_title $sort";
341        } elseif ( $mode == 'all' ) {
342            // Targeting index ar_name_title_timestamp
343            if ( $params['namespace'] === null || count( array_unique( $params['namespace'] ) ) > 1 ) {
344                $orderby[] = "ar_namespace $sort";
345            }
346            $orderby[] = "ar_title $sort";
347            $orderby[] = "ar_timestamp $sort";
348            $orderby[] = "ar_id $sort";
349        } else {
350            // Targeting index usertext_timestamp
351            // 'user' is always constant.
352            $orderby[] = "ar_timestamp $sort";
353            $orderby[] = "ar_id $sort";
354        }
355        $this->addOption( 'ORDER BY', $orderby );
356
357        $res = $this->select( __METHOD__ );
358
359        if ( $resultPageSet === null ) {
360            $this->executeGenderCacheFromResultWrapper( $res, __METHOD__, 'ar' );
361        }
362
363        $pageMap = []; // Maps ns&title to array index
364        $count = 0;
365        $nextIndex = 0;
366        $generated = [];
367        foreach ( $res as $row ) {
368            if ( ++$count > $this->limit ) {
369                // We've had enough
370                if ( $optimizeGenerateTitles ) {
371                    $this->setContinueEnumParameter( 'continue', "$row->ar_namespace|$row->ar_title" );
372                } elseif ( $mode == 'all' ) {
373                    $this->setContinueEnumParameter( 'continue',
374                        "$row->ar_namespace|$row->ar_title|$row->ar_timestamp|$row->ar_id"
375                    );
376                } else {
377                    $this->setContinueEnumParameter( 'continue', "$row->ar_timestamp|$row->ar_id" );
378                }
379                break;
380            }
381
382            // Miser mode namespace check
383            if ( $miser_ns !== null && !in_array( $row->ar_namespace, $miser_ns ) ) {
384                continue;
385            }
386
387            if ( $resultPageSet !== null ) {
388                if ( $params['generatetitles'] ) {
389                    $key = "{$row->ar_namespace}:{$row->ar_title}";
390                    if ( !isset( $generated[$key] ) ) {
391                        $generated[$key] = Title::makeTitle( $row->ar_namespace, $row->ar_title );
392                    }
393                } else {
394                    $generated[] = $row->ar_rev_id;
395                }
396            } else {
397                $revision = $this->revisionStore->newRevisionFromArchiveRow( $row );
398                $rev = $this->extractRevisionInfo( $revision, $row );
399
400                if ( !isset( $pageMap[$row->ar_namespace][$row->ar_title] ) ) {
401                    $index = $nextIndex++;
402                    $pageMap[$row->ar_namespace][$row->ar_title] = $index;
403                    $title = Title::newFromPageIdentity( $revision->getPage() );
404                    $a = [
405                        'pageid' => $title->getArticleID(),
406                        'revisions' => [ $rev ],
407                    ];
408                    ApiResult::setIndexedTagName( $a['revisions'], 'rev' );
409                    ApiQueryBase::addTitleInfo( $a, $title );
410                    $fit = $result->addValue( [ 'query', $this->getModuleName() ], $index, $a );
411                } else {
412                    $index = $pageMap[$row->ar_namespace][$row->ar_title];
413                    $fit = $result->addValue(
414                        [ 'query', $this->getModuleName(), $index, 'revisions' ],
415                        null, $rev );
416                }
417                if ( !$fit ) {
418                    if ( $mode == 'all' ) {
419                        $this->setContinueEnumParameter( 'continue',
420                            "$row->ar_namespace|$row->ar_title|$row->ar_timestamp|$row->ar_id"
421                        );
422                    } else {
423                        $this->setContinueEnumParameter( 'continue', "$row->ar_timestamp|$row->ar_id" );
424                    }
425                    break;
426                }
427            }
428        }
429
430        if ( $resultPageSet !== null ) {
431            if ( $params['generatetitles'] ) {
432                $resultPageSet->populateFromTitles( $generated );
433            } else {
434                $resultPageSet->populateFromRevisionIDs( $generated );
435            }
436        } else {
437            $result->addIndexedTagName( [ 'query', $this->getModuleName() ], 'page' );
438        }
439    }
440
441    /** @inheritDoc */
442    public function getAllowedParams() {
443        $ret = parent::getAllowedParams() + [
444            'user' => [
445                ParamValidator::PARAM_TYPE => 'user',
446                UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name', 'ip', 'temp', 'id', 'interwiki' ],
447            ],
448            'namespace' => [
449                ParamValidator::PARAM_ISMULTI => true,
450                ParamValidator::PARAM_TYPE => 'namespace',
451            ],
452            'start' => [
453                ParamValidator::PARAM_TYPE => 'timestamp',
454                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'useronly' ] ],
455            ],
456            'end' => [
457                ParamValidator::PARAM_TYPE => 'timestamp',
458                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'useronly' ] ],
459            ],
460            'dir' => [
461                ParamValidator::PARAM_TYPE => [
462                    'newer',
463                    'older'
464                ],
465                ParamValidator::PARAM_DEFAULT => 'older',
466                ApiBase::PARAM_HELP_MSG => 'api-help-param-direction',
467                ApiBase::PARAM_HELP_MSG_PER_VALUE => [
468                    'newer' => 'api-help-paramvalue-direction-newer',
469                    'older' => 'api-help-paramvalue-direction-older',
470                ],
471            ],
472            'from' => [
473                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'nonuseronly' ] ],
474            ],
475            'to' => [
476                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'nonuseronly' ] ],
477            ],
478            'prefix' => [
479                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'nonuseronly' ] ],
480            ],
481            'excludeuser' => [
482                ParamValidator::PARAM_TYPE => 'user',
483                UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name', 'ip', 'temp', 'id', 'interwiki' ],
484                ApiBase::PARAM_HELP_MSG_INFO => [ [ 'nonuseronly' ] ],
485            ],
486            'tag' => null,
487            'continue' => [
488                ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
489            ],
490            'generatetitles' => [
491                ParamValidator::PARAM_DEFAULT => false
492            ],
493        ];
494
495        if ( $this->getConfig()->get( MainConfigNames::MiserMode ) ) {
496            $ret['user'][ApiBase::PARAM_HELP_MSG_APPEND] = [
497                'apihelp-query+alldeletedrevisions-param-miser-user-namespace',
498            ];
499            $ret['namespace'][ApiBase::PARAM_HELP_MSG_APPEND] = [
500                'apihelp-query+alldeletedrevisions-param-miser-user-namespace',
501            ];
502        }
503
504        return $ret;
505    }
506
507    /** @inheritDoc */
508    protected function getExamplesMessages() {
509        return [
510            'action=query&list=alldeletedrevisions&adruser=Example&adrlimit=50'
511                => 'apihelp-query+alldeletedrevisions-example-user',
512            'action=query&list=alldeletedrevisions&adrdir=newer&adrnamespace=0&adrlimit=50'
513                => 'apihelp-query+alldeletedrevisions-example-ns-main',
514        ];
515    }
516
517    /** @inheritDoc */
518    public function getHelpUrls() {
519        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Alldeletedrevisions';
520    }
521}
522
523/** @deprecated class alias since 1.43 */
524class_alias( ApiQueryAllDeletedRevisions::class, 'ApiQueryAllDeletedRevisions' );