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