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\MediaWikiServices; 24use Wikimedia\Rdbms\IDatabase; 25 26/** 27 * This is the main query class. It behaves similar to ApiMain: based on the 28 * parameters given, it will create a list of titles to work on (an ApiPageSet 29 * object), instantiate and execute various property/list/meta modules, and 30 * assemble all resulting data into a single ApiResult object. 31 * 32 * In generator mode, a generator will be executed first to populate a second 33 * ApiPageSet object, and that object will be used for all subsequent modules. 34 * 35 * @ingroup API 36 */ 37class ApiQuery extends ApiBase { 38 39 /** 40 * List of Api Query prop modules 41 */ 42 private const QUERY_PROP_MODULES = [ 43 'categories' => ApiQueryCategories::class, 44 'categoryinfo' => ApiQueryCategoryInfo::class, 45 'contributors' => ApiQueryContributors::class, 46 'deletedrevisions' => ApiQueryDeletedRevisions::class, 47 'duplicatefiles' => ApiQueryDuplicateFiles::class, 48 'extlinks' => ApiQueryExternalLinks::class, 49 'fileusage' => ApiQueryBacklinksprop::class, 50 'images' => ApiQueryImages::class, 51 'imageinfo' => ApiQueryImageInfo::class, 52 'info' => [ 53 'class' => ApiQueryInfo::class, 54 'services' => [ 55 'ContentLanguage', 56 'LinkBatchFactory', 57 'NamespaceInfo', 58 'TitleFactory', 59 'WatchedItemStore', 60 ], 61 ], 62 'links' => ApiQueryLinks::class, 63 'linkshere' => ApiQueryBacklinksprop::class, 64 'iwlinks' => ApiQueryIWLinks::class, 65 'langlinks' => ApiQueryLangLinks::class, 66 'pageprops' => ApiQueryPageProps::class, 67 'redirects' => ApiQueryBacklinksprop::class, 68 'revisions' => ApiQueryRevisions::class, 69 'stashimageinfo' => ApiQueryStashImageInfo::class, 70 'templates' => ApiQueryLinks::class, 71 'transcludedin' => ApiQueryBacklinksprop::class, 72 ]; 73 74 /** 75 * List of Api Query list modules 76 */ 77 private const QUERY_LIST_MODULES = [ 78 'allcategories' => ApiQueryAllCategories::class, 79 'alldeletedrevisions' => ApiQueryAllDeletedRevisions::class, 80 'allfileusages' => ApiQueryAllLinks::class, 81 'allimages' => ApiQueryAllImages::class, 82 'alllinks' => ApiQueryAllLinks::class, 83 'allpages' => ApiQueryAllPages::class, 84 'allredirects' => ApiQueryAllLinks::class, 85 'allrevisions' => ApiQueryAllRevisions::class, 86 'mystashedfiles' => ApiQueryMyStashedFiles::class, 87 'alltransclusions' => ApiQueryAllLinks::class, 88 'allusers' => ApiQueryAllUsers::class, 89 'backlinks' => ApiQueryBacklinks::class, 90 'blocks' => ApiQueryBlocks::class, 91 'categorymembers' => ApiQueryCategoryMembers::class, 92 'deletedrevs' => ApiQueryDeletedrevs::class, 93 'embeddedin' => ApiQueryBacklinks::class, 94 'exturlusage' => ApiQueryExtLinksUsage::class, 95 'filearchive' => ApiQueryFilearchive::class, 96 'imageusage' => ApiQueryBacklinks::class, 97 'iwbacklinks' => ApiQueryIWBacklinks::class, 98 'langbacklinks' => ApiQueryLangBacklinks::class, 99 'logevents' => ApiQueryLogEvents::class, 100 'pageswithprop' => ApiQueryPagesWithProp::class, 101 'pagepropnames' => ApiQueryPagePropNames::class, 102 'prefixsearch' => ApiQueryPrefixSearch::class, 103 'protectedtitles' => ApiQueryProtectedTitles::class, 104 'querypage' => ApiQueryQueryPage::class, 105 'random' => ApiQueryRandom::class, 106 'recentchanges' => ApiQueryRecentChanges::class, 107 'search' => ApiQuerySearch::class, 108 'tags' => ApiQueryTags::class, 109 'usercontribs' => [ 110 'class' => ApiQueryUserContribs::class, 111 'services' => [ 112 'UserIdentityLookup', 113 ], 114 ], 115 'users' => ApiQueryUsers::class, 116 'watchlist' => ApiQueryWatchlist::class, 117 'watchlistraw' => ApiQueryWatchlistRaw::class, 118 ]; 119 120 /** 121 * List of Api Query meta modules 122 */ 123 private const QUERY_META_MODULES = [ 124 'allmessages' => ApiQueryAllMessages::class, 125 'authmanagerinfo' => ApiQueryAuthManagerInfo::class, 126 'siteinfo' => [ 127 'class' => ApiQuerySiteinfo::class, 128 'services' => [ 129 'UserOptionsLookup', 130 ] 131 ], 132 'userinfo' => [ 133 'class' => ApiQueryUserInfo::class, 134 'services' => [ 135 'TalkPageNotificationManager', 136 'WatchedItemStore', 137 'UserEditTracker' 138 ] 139 ], 140 'filerepoinfo' => ApiQueryFileRepoInfo::class, 141 'tokens' => ApiQueryTokens::class, 142 'languageinfo' => [ 143 'class' => ApiQueryLanguageinfo::class, 144 'services' => [ 145 'LanguageFactory', 146 'LanguageNameUtils', 147 'LanguageFallback', 148 'LanguageConverterFactory', 149 ], 150 ], 151 ]; 152 153 /** 154 * @var ApiPageSet 155 */ 156 private $mPageSet; 157 158 private $mParams; 159 private $mNamedDB = []; 160 private $mModuleMgr; 161 162 /** 163 * @param ApiMain $main 164 * @param string $action 165 */ 166 public function __construct( ApiMain $main, $action ) { 167 parent::__construct( $main, $action ); 168 169 $this->mModuleMgr = new ApiModuleManager( 170 $this, 171 MediaWikiServices::getInstance()->getObjectFactory() 172 ); 173 174 // Allow custom modules to be added in LocalSettings.php 175 $config = $this->getConfig(); 176 $this->mModuleMgr->addModules( self::QUERY_PROP_MODULES, 'prop' ); 177 $this->mModuleMgr->addModules( $config->get( 'APIPropModules' ), 'prop' ); 178 $this->mModuleMgr->addModules( self::QUERY_LIST_MODULES, 'list' ); 179 $this->mModuleMgr->addModules( $config->get( 'APIListModules' ), 'list' ); 180 $this->mModuleMgr->addModules( self::QUERY_META_MODULES, 'meta' ); 181 $this->mModuleMgr->addModules( $config->get( 'APIMetaModules' ), 'meta' ); 182 183 $this->getHookRunner()->onApiQuery__moduleManager( $this->mModuleMgr ); 184 185 // Create PageSet that will process titles/pageids/revids/generator 186 $this->mPageSet = new ApiPageSet( $this ); 187 } 188 189 /** 190 * Overrides to return this instance's module manager. 191 * @return ApiModuleManager 192 */ 193 public function getModuleManager() { 194 return $this->mModuleMgr; 195 } 196 197 /** 198 * Get the query database connection with the given name. 199 * If no such connection has been requested before, it will be created. 200 * Subsequent calls with the same $name will return the same connection 201 * as the first, regardless of the values of $db and $groups 202 * @param string $name Name to assign to the database connection 203 * @param int $db One of the DB_* constants 204 * @param string|string[] $groups Query groups 205 * @return IDatabase 206 */ 207 public function getNamedDB( $name, $db, $groups ) { 208 if ( !array_key_exists( $name, $this->mNamedDB ) ) { 209 $this->mNamedDB[$name] = wfGetDB( $db, $groups ); 210 } 211 212 return $this->mNamedDB[$name]; 213 } 214 215 /** 216 * Gets the set of pages the user has requested (or generated) 217 * @return ApiPageSet 218 */ 219 public function getPageSet() { 220 return $this->mPageSet; 221 } 222 223 /** 224 * @return ApiFormatRaw|null 225 */ 226 public function getCustomPrinter() { 227 // If &exportnowrap is set, use the raw formatter 228 if ( $this->getParameter( 'export' ) && 229 $this->getParameter( 'exportnowrap' ) 230 ) { 231 return new ApiFormatRaw( $this->getMain(), 232 $this->getMain()->createPrinterByName( 'xml' ) ); 233 } else { 234 return null; 235 } 236 } 237 238 /** 239 * Query execution happens in the following steps: 240 * #1 Create a PageSet object with any pages requested by the user 241 * #2 If using a generator, execute it to get a new ApiPageSet object 242 * #3 Instantiate all requested modules. 243 * This way the PageSet object will know what shared data is required, 244 * and minimize DB calls. 245 * #4 Output all normalization and redirect resolution information 246 * #5 Execute all requested modules 247 */ 248 public function execute() { 249 $this->mParams = $this->extractRequestParams(); 250 251 // Instantiate requested modules 252 $allModules = []; 253 $this->instantiateModules( $allModules, 'prop' ); 254 $propModules = array_keys( $allModules ); 255 $this->instantiateModules( $allModules, 'list' ); 256 $this->instantiateModules( $allModules, 'meta' ); 257 258 // Filter modules based on continue parameter 259 $continuationManager = new ApiContinuationManager( $this, $allModules, $propModules ); 260 $this->setContinuationManager( $continuationManager ); 261 /** @var ApiQueryBase[] $modules */ 262 $modules = $continuationManager->getRunModules(); 263 '@phan-var ApiQueryBase[] $modules'; 264 265 if ( !$continuationManager->isGeneratorDone() ) { 266 // Query modules may optimize data requests through the $this->getPageSet() 267 // object by adding extra fields from the page table. 268 foreach ( $modules as $module ) { 269 $module->requestExtraData( $this->mPageSet ); 270 } 271 // Populate page/revision information 272 $this->mPageSet->execute(); 273 // Record page information (title, namespace, if exists, etc) 274 $this->outputGeneralPageInfo(); 275 } else { 276 $this->mPageSet->executeDryRun(); 277 } 278 279 $cacheMode = $this->mPageSet->getCacheMode(); 280 281 // Execute all unfinished modules 282 foreach ( $modules as $module ) { 283 $params = $module->extractRequestParams(); 284 $cacheMode = $this->mergeCacheMode( 285 $cacheMode, $module->getCacheMode( $params ) ); 286 $module->execute(); 287 $this->getHookRunner()->onAPIQueryAfterExecute( $module ); 288 } 289 290 // Set the cache mode 291 $this->getMain()->setCacheMode( $cacheMode ); 292 293 // Write the continuation data into the result 294 $this->setContinuationManager( null ); 295 if ( $this->mParams['rawcontinue'] ) { 296 $data = $continuationManager->getRawNonContinuation(); 297 if ( $data ) { 298 $this->getResult()->addValue( null, 'query-noncontinue', $data, 299 ApiResult::ADD_ON_TOP | ApiResult::NO_SIZE_CHECK ); 300 } 301 $data = $continuationManager->getRawContinuation(); 302 if ( $data ) { 303 $this->getResult()->addValue( null, 'query-continue', $data, 304 ApiResult::ADD_ON_TOP | ApiResult::NO_SIZE_CHECK ); 305 } 306 } else { 307 $continuationManager->setContinuationIntoResult( $this->getResult() ); 308 } 309 } 310 311 /** 312 * Update a cache mode string, applying the cache mode of a new module to it. 313 * The cache mode may increase in the level of privacy, but public modules 314 * added to private data do not decrease the level of privacy. 315 * 316 * @param string $cacheMode 317 * @param string $modCacheMode 318 * @return string 319 */ 320 protected function mergeCacheMode( $cacheMode, $modCacheMode ) { 321 if ( $modCacheMode === 'anon-public-user-private' ) { 322 if ( $cacheMode !== 'private' ) { 323 $cacheMode = 'anon-public-user-private'; 324 } 325 } elseif ( $modCacheMode === 'public' ) { 326 // do nothing, if it's public already it will stay public 327 } else { 328 $cacheMode = 'private'; 329 } 330 331 return $cacheMode; 332 } 333 334 /** 335 * Create instances of all modules requested by the client 336 * @param array &$modules To append instantiated modules to 337 * @param string $param Parameter name to read modules from 338 */ 339 private function instantiateModules( &$modules, $param ) { 340 $wasPosted = $this->getRequest()->wasPosted(); 341 if ( isset( $this->mParams[$param] ) ) { 342 foreach ( $this->mParams[$param] as $moduleName ) { 343 $instance = $this->mModuleMgr->getModule( $moduleName, $param ); 344 if ( $instance === null ) { 345 ApiBase::dieDebug( __METHOD__, 'Error instantiating module' ); 346 } 347 if ( !$wasPosted && $instance->mustBePosted() ) { 348 $this->dieWithErrorOrDebug( [ 'apierror-mustbeposted', $moduleName ] ); 349 } 350 // Ignore duplicates. TODO 2.0: die()? 351 if ( !array_key_exists( $moduleName, $modules ) ) { 352 $modules[$moduleName] = $instance; 353 } 354 } 355 } 356 } 357 358 /** 359 * Appends an element for each page in the current pageSet with the 360 * most general information (id, title), plus any title normalizations 361 * and missing or invalid title/pageids/revids. 362 */ 363 private function outputGeneralPageInfo() { 364 $pageSet = $this->getPageSet(); 365 $result = $this->getResult(); 366 367 // We can't really handle max-result-size failure here, but we need to 368 // check anyway in case someone set the limit stupidly low. 369 $fit = true; 370 371 $values = $pageSet->getNormalizedTitlesAsResult( $result ); 372 if ( $values ) { 373 // @phan-suppress-next-line PhanRedundantCondition 374 $fit = $fit && $result->addValue( 'query', 'normalized', $values ); 375 } 376 $values = $pageSet->getConvertedTitlesAsResult( $result ); 377 if ( $values ) { 378 $fit = $fit && $result->addValue( 'query', 'converted', $values ); 379 } 380 $values = $pageSet->getInterwikiTitlesAsResult( $result, $this->mParams['iwurl'] ); 381 if ( $values ) { 382 $fit = $fit && $result->addValue( 'query', 'interwiki', $values ); 383 } 384 $values = $pageSet->getRedirectTitlesAsResult( $result ); 385 if ( $values ) { 386 $fit = $fit && $result->addValue( 'query', 'redirects', $values ); 387 } 388 $values = $pageSet->getMissingRevisionIDsAsResult( $result ); 389 if ( $values ) { 390 $fit = $fit && $result->addValue( 'query', 'badrevids', $values ); 391 } 392 393 // Page elements 394 $pages = []; 395 396 // Report any missing titles 397 foreach ( $pageSet->getMissingTitles() as $fakeId => $title ) { 398 $vals = []; 399 ApiQueryBase::addTitleInfo( $vals, $title ); 400 $vals['missing'] = true; 401 if ( $title->isKnown() ) { 402 $vals['known'] = true; 403 } 404 $pages[$fakeId] = $vals; 405 } 406 // Report any invalid titles 407 foreach ( $pageSet->getInvalidTitlesAndReasons() as $fakeId => $data ) { 408 $pages[$fakeId] = $data + [ 'invalid' => true ]; 409 } 410 // Report any missing page ids 411 foreach ( $pageSet->getMissingPageIDs() as $pageid ) { 412 $pages[$pageid] = [ 413 'pageid' => $pageid, 414 'missing' => true, 415 ]; 416 } 417 // Report special pages 418 /** @var Title $title */ 419 foreach ( $pageSet->getSpecialTitles() as $fakeId => $title ) { 420 $vals = []; 421 ApiQueryBase::addTitleInfo( $vals, $title ); 422 $vals['special'] = true; 423 if ( !$title->isKnown() ) { 424 $vals['missing'] = true; 425 } 426 $pages[$fakeId] = $vals; 427 } 428 429 // Output general page information for found titles 430 foreach ( $pageSet->getGoodTitles() as $pageid => $title ) { 431 $vals = []; 432 $vals['pageid'] = $pageid; 433 ApiQueryBase::addTitleInfo( $vals, $title ); 434 $pages[$pageid] = $vals; 435 } 436 437 if ( count( $pages ) ) { 438 $pageSet->populateGeneratorData( $pages ); 439 ApiResult::setArrayType( $pages, 'BCarray' ); 440 441 if ( $this->mParams['indexpageids'] ) { 442 $pageIDs = array_keys( ApiResult::stripMetadataNonRecursive( $pages ) ); 443 // json treats all map keys as strings - converting to match 444 $pageIDs = array_map( 'strval', $pageIDs ); 445 ApiResult::setIndexedTagName( $pageIDs, 'id' ); 446 $fit = $fit && $result->addValue( 'query', 'pageids', $pageIDs ); 447 } 448 449 ApiResult::setIndexedTagName( $pages, 'page' ); 450 $fit = $fit && $result->addValue( 'query', 'pages', $pages ); 451 } 452 453 if ( !$fit ) { 454 $this->dieWithError( 'apierror-badconfig-resulttoosmall', 'badconfig' ); 455 } 456 457 if ( $this->mParams['export'] ) { 458 $this->doExport( $pageSet, $result ); 459 } 460 } 461 462 /** 463 * @param ApiPageSet $pageSet Pages to be exported 464 * @param ApiResult $result Result to output to 465 */ 466 private function doExport( $pageSet, $result ) { 467 $exportTitles = []; 468 $titles = $pageSet->getGoodTitles(); 469 if ( count( $titles ) ) { 470 /** @var Title $title */ 471 foreach ( $titles as $title ) { 472 if ( $this->getAuthority()->authorizeRead( 'read', $title ) ) { 473 $exportTitles[] = $title; 474 } 475 } 476 } 477 478 $exporter = new WikiExporter( $this->getDB() ); 479 $sink = new DumpStringOutput; 480 $exporter->setOutputSink( $sink ); 481 $exporter->setSchemaVersion( $this->mParams['exportschema'] ); 482 $exporter->openStream(); 483 foreach ( $exportTitles as $title ) { 484 $exporter->pageByTitle( $title ); 485 } 486 $exporter->closeStream(); 487 488 // Don't check the size of exported stuff 489 // It's not continuable, so it would cause more 490 // problems than it'd solve 491 if ( $this->mParams['exportnowrap'] ) { 492 $result->reset(); 493 // Raw formatter will handle this 494 $result->addValue( null, 'text', $sink, ApiResult::NO_SIZE_CHECK ); 495 $result->addValue( null, 'mime', 'text/xml', ApiResult::NO_SIZE_CHECK ); 496 $result->addValue( null, 'filename', 'export.xml', ApiResult::NO_SIZE_CHECK ); 497 } else { 498 $result->addValue( 'query', 'export', $sink, ApiResult::NO_SIZE_CHECK ); 499 $result->addValue( 'query', ApiResult::META_BC_SUBELEMENTS, [ 'export' ] ); 500 } 501 } 502 503 public function getAllowedParams( $flags = 0 ) { 504 $result = [ 505 'prop' => [ 506 ApiBase::PARAM_ISMULTI => true, 507 ApiBase::PARAM_TYPE => 'submodule', 508 ], 509 'list' => [ 510 ApiBase::PARAM_ISMULTI => true, 511 ApiBase::PARAM_TYPE => 'submodule', 512 ], 513 'meta' => [ 514 ApiBase::PARAM_ISMULTI => true, 515 ApiBase::PARAM_TYPE => 'submodule', 516 ], 517 'indexpageids' => false, 518 'export' => false, 519 'exportnowrap' => false, 520 'exportschema' => [ 521 ApiBase::PARAM_DFLT => WikiExporter::schemaVersion(), 522 ApiBase::PARAM_TYPE => XmlDumpWriter::$supportedSchemas, 523 ], 524 'iwurl' => false, 525 'continue' => [ 526 ApiBase::PARAM_HELP_MSG => 'api-help-param-continue', 527 ], 528 'rawcontinue' => false, 529 ]; 530 if ( $flags ) { 531 $result += $this->getPageSet()->getFinalParams( $flags ); 532 } 533 534 return $result; 535 } 536 537 public function isReadMode() { 538 // We need to make an exception for certain meta modules that should be 539 // accessible even without the 'read' right. Restrict the exception as 540 // much as possible: no other modules allowed, and no pageset 541 // parameters either. We do allow the 'rawcontinue' and 'indexpageids' 542 // parameters since frameworks might add these unconditionally and they 543 // can't expose anything here. 544 $allowedParams = [ 'rawcontinue' => 1, 'indexpageids' => 1 ]; 545 $this->mParams = $this->extractRequestParams(); 546 $request = $this->getRequest(); 547 foreach ( $this->mParams + $this->getPageSet()->extractRequestParams() as $param => $value ) { 548 $needed = $param === 'meta'; 549 if ( !isset( $allowedParams[$param] ) && $request->getCheck( $param ) !== $needed ) { 550 return true; 551 } 552 } 553 554 // Ask each module if it requires read mode. Any true => this returns 555 // true. 556 $modules = []; 557 $this->instantiateModules( $modules, 'meta' ); 558 foreach ( $modules as $module ) { 559 if ( $module->isReadMode() ) { 560 return true; 561 } 562 } 563 564 return false; 565 } 566 567 protected function getExamplesMessages() { 568 return [ 569 'action=query&prop=revisions&meta=siteinfo&' . 570 'titles=Main%20Page&rvprop=user|comment&continue=' 571 => 'apihelp-query-example-revisions', 572 'action=query&generator=allpages&gapprefix=API/&prop=revisions&continue=' 573 => 'apihelp-query-example-allpages', 574 ]; 575 } 576 577 public function getHelpUrls() { 578 return [ 579 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Query', 580 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Meta', 581 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Properties', 582 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Lists', 583 ]; 584 } 585} 586