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