User rights API: Abstract out some stuff about core's form into separate methods
[lhc/web/wiklou.git] / includes / api / ApiQuery.php
1 <?php
2 /**
3 *
4 *
5 * Created on Sep 7, 2006
6 *
7 * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
8 *
9 * This program is free software; you can redistribute it and/or modify
10 * it under the terms of the GNU General Public License as published by
11 * the Free Software Foundation; either version 2 of the License, or
12 * (at your option) any later version.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License along
20 * with this program; if not, write to the Free Software Foundation, Inc.,
21 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
22 * http://www.gnu.org/copyleft/gpl.html
23 *
24 * @file
25 */
26
27 /**
28 * This is the main query class. It behaves similar to ApiMain: based on the
29 * parameters given, it will create a list of titles to work on (an ApiPageSet
30 * object), instantiate and execute various property/list/meta modules, and
31 * assemble all resulting data into a single ApiResult object.
32 *
33 * In generator mode, a generator will be executed first to populate a second
34 * ApiPageSet object, and that object will be used for all subsequent modules.
35 *
36 * @ingroup API
37 */
38 class ApiQuery extends ApiBase {
39
40 /**
41 * List of Api Query prop modules
42 * @var array
43 */
44 private static $QueryPropModules = array(
45 'categories' => 'ApiQueryCategories',
46 'categoryinfo' => 'ApiQueryCategoryInfo',
47 'contributors' => 'ApiQueryContributors',
48 'duplicatefiles' => 'ApiQueryDuplicateFiles',
49 'extlinks' => 'ApiQueryExternalLinks',
50 'fileusage' => 'ApiQueryBacklinksprop',
51 'images' => 'ApiQueryImages',
52 'imageinfo' => 'ApiQueryImageInfo',
53 'info' => 'ApiQueryInfo',
54 'links' => 'ApiQueryLinks',
55 'linkshere' => 'ApiQueryBacklinksprop',
56 'iwlinks' => 'ApiQueryIWLinks',
57 'langlinks' => 'ApiQueryLangLinks',
58 'pageprops' => 'ApiQueryPageProps',
59 'redirects' => 'ApiQueryBacklinksprop',
60 'revisions' => 'ApiQueryRevisions',
61 'stashimageinfo' => 'ApiQueryStashImageInfo',
62 'templates' => 'ApiQueryLinks',
63 'transcludedin' => 'ApiQueryBacklinksprop',
64 );
65
66 /**
67 * List of Api Query list modules
68 * @var array
69 */
70 private static $QueryListModules = array(
71 'allcategories' => 'ApiQueryAllCategories',
72 'allfileusages' => 'ApiQueryAllLinks',
73 'allimages' => 'ApiQueryAllImages',
74 'alllinks' => 'ApiQueryAllLinks',
75 'allpages' => 'ApiQueryAllPages',
76 'allredirects' => 'ApiQueryAllLinks',
77 'alltransclusions' => 'ApiQueryAllLinks',
78 'allusers' => 'ApiQueryAllUsers',
79 'backlinks' => 'ApiQueryBacklinks',
80 'blocks' => 'ApiQueryBlocks',
81 'categorymembers' => 'ApiQueryCategoryMembers',
82 'deletedrevs' => 'ApiQueryDeletedrevs',
83 'embeddedin' => 'ApiQueryBacklinks',
84 'exturlusage' => 'ApiQueryExtLinksUsage',
85 'filearchive' => 'ApiQueryFilearchive',
86 'imageusage' => 'ApiQueryBacklinks',
87 'iwbacklinks' => 'ApiQueryIWBacklinks',
88 'langbacklinks' => 'ApiQueryLangBacklinks',
89 'logevents' => 'ApiQueryLogEvents',
90 'pageswithprop' => 'ApiQueryPagesWithProp',
91 'pagepropnames' => 'ApiQueryPagePropNames',
92 'prefixsearch' => 'ApiQueryPrefixSearch',
93 'protectedtitles' => 'ApiQueryProtectedTitles',
94 'querypage' => 'ApiQueryQueryPage',
95 'random' => 'ApiQueryRandom',
96 'recentchanges' => 'ApiQueryRecentChanges',
97 'search' => 'ApiQuerySearch',
98 'tags' => 'ApiQueryTags',
99 'usercontribs' => 'ApiQueryContributions',
100 'users' => 'ApiQueryUsers',
101 'watchlist' => 'ApiQueryWatchlist',
102 'watchlistraw' => 'ApiQueryWatchlistRaw',
103 );
104
105 /**
106 * List of Api Query meta modules
107 * @var array
108 */
109 private static $QueryMetaModules = array(
110 'allmessages' => 'ApiQueryAllMessages',
111 'siteinfo' => 'ApiQuerySiteinfo',
112 'userinfo' => 'ApiQueryUserInfo',
113 'filerepoinfo' => 'ApiQueryFileRepoInfo',
114 'tokens' => 'ApiQueryTokens',
115 );
116
117 /**
118 * @var ApiPageSet
119 */
120 private $mPageSet;
121
122 private $mParams;
123 private $mNamedDB = array();
124 private $mModuleMgr;
125
126 /**
127 * @param ApiMain $main
128 * @param string $action
129 */
130 public function __construct( ApiMain $main, $action ) {
131 parent::__construct( $main, $action );
132
133 $this->mModuleMgr = new ApiModuleManager( $this );
134
135 // Allow custom modules to be added in LocalSettings.php
136 $config = $this->getConfig();
137 $this->mModuleMgr->addModules( self::$QueryPropModules, 'prop' );
138 $this->mModuleMgr->addModules( $config->get( 'APIPropModules' ), 'prop' );
139 $this->mModuleMgr->addModules( self::$QueryListModules, 'list' );
140 $this->mModuleMgr->addModules( $config->get( 'APIListModules' ), 'list' );
141 $this->mModuleMgr->addModules( self::$QueryMetaModules, 'meta' );
142 $this->mModuleMgr->addModules( $config->get( 'APIMetaModules' ), 'meta' );
143
144 // Create PageSet that will process titles/pageids/revids/generator
145 $this->mPageSet = new ApiPageSet( $this );
146 }
147
148 /**
149 * Overrides to return this instance's module manager.
150 * @return ApiModuleManager
151 */
152 public function getModuleManager() {
153 return $this->mModuleMgr;
154 }
155
156 /**
157 * Get the query database connection with the given name.
158 * If no such connection has been requested before, it will be created.
159 * Subsequent calls with the same $name will return the same connection
160 * as the first, regardless of the values of $db and $groups
161 * @param string $name Name to assign to the database connection
162 * @param int $db One of the DB_* constants
163 * @param array $groups Query groups
164 * @return DatabaseBase
165 */
166 public function getNamedDB( $name, $db, $groups ) {
167 if ( !array_key_exists( $name, $this->mNamedDB ) ) {
168 $this->profileDBIn();
169 $this->mNamedDB[$name] = wfGetDB( $db, $groups );
170 $this->profileDBOut();
171 }
172
173 return $this->mNamedDB[$name];
174 }
175
176 /**
177 * Gets the set of pages the user has requested (or generated)
178 * @return ApiPageSet
179 */
180 public function getPageSet() {
181 return $this->mPageSet;
182 }
183
184 /**
185 * Get the array mapping module names to class names
186 * @deprecated since 1.21, use getModuleManager()'s methods instead
187 * @return array Array(modulename => classname)
188 */
189 public function getModules() {
190 wfDeprecated( __METHOD__, '1.21' );
191
192 return $this->getModuleManager()->getNamesWithClasses();
193 }
194
195 /**
196 * Get the generators array mapping module names to class names
197 * @deprecated since 1.21, list of generators is maintained by ApiPageSet
198 * @return array Array(modulename => classname)
199 */
200 public function getGenerators() {
201 wfDeprecated( __METHOD__, '1.21' );
202 $gens = array();
203 foreach ( $this->mModuleMgr->getNamesWithClasses() as $name => $class ) {
204 if ( is_subclass_of( $class, 'ApiQueryGeneratorBase' ) ) {
205 $gens[$name] = $class;
206 }
207 }
208
209 return $gens;
210 }
211
212 /**
213 * Get whether the specified module is a prop, list or a meta query module
214 * @deprecated since 1.21, use getModuleManager()->getModuleGroup()
215 * @param string $moduleName Name of the module to find type for
216 * @return string|null
217 */
218 function getModuleType( $moduleName ) {
219 return $this->getModuleManager()->getModuleGroup( $moduleName );
220 }
221
222 /**
223 * @return ApiFormatRaw|null
224 */
225 public function getCustomPrinter() {
226 // If &exportnowrap is set, use the raw formatter
227 if ( $this->getParameter( 'export' ) &&
228 $this->getParameter( 'exportnowrap' )
229 ) {
230 return new ApiFormatRaw( $this->getMain(),
231 $this->getMain()->createPrinterByName( 'xml' ) );
232 } else {
233 return null;
234 }
235 }
236
237 /**
238 * Query execution happens in the following steps:
239 * #1 Create a PageSet object with any pages requested by the user
240 * #2 If using a generator, execute it to get a new ApiPageSet object
241 * #3 Instantiate all requested modules.
242 * This way the PageSet object will know what shared data is required,
243 * and minimize DB calls.
244 * #4 Output all normalization and redirect resolution information
245 * #5 Execute all requested modules
246 */
247 public function execute() {
248 $this->mParams = $this->extractRequestParams();
249
250 // Instantiate requested modules
251 $allModules = array();
252 $this->instantiateModules( $allModules, 'prop' );
253 $propModules = array_keys( $allModules );
254 $this->instantiateModules( $allModules, 'list' );
255 $this->instantiateModules( $allModules, 'meta' );
256
257 // Filter modules based on continue parameter
258 list( $generatorDone, $modules ) = $this->getResult()->beginContinuation(
259 $this->mParams['continue'], $allModules, $propModules
260 );
261
262 if ( !$generatorDone ) {
263 // Query modules may optimize data requests through the $this->getPageSet()
264 // object by adding extra fields from the page table.
265 foreach ( $modules as $module ) {
266 $module->requestExtraData( $this->mPageSet );
267 }
268 // Populate page/revision information
269 $this->mPageSet->execute();
270 // Record page information (title, namespace, if exists, etc)
271 $this->outputGeneralPageInfo();
272 } else {
273 $this->mPageSet->executeDryRun();
274 }
275
276 $cacheMode = $this->mPageSet->getCacheMode();
277
278 // Execute all unfinished modules
279 /** @var $module ApiQueryBase */
280 foreach ( $modules as $module ) {
281 $params = $module->extractRequestParams();
282 $cacheMode = $this->mergeCacheMode(
283 $cacheMode, $module->getCacheMode( $params ) );
284 $module->profileIn();
285 $module->execute();
286 wfRunHooks( 'APIQueryAfterExecute', array( &$module ) );
287 $module->profileOut();
288 }
289
290 // Set the cache mode
291 $this->getMain()->setCacheMode( $cacheMode );
292
293 // Write the continuation data into the result
294 $this->getResult()->endContinuation(
295 $this->mParams['continue'] === null ? 'raw' : 'standard'
296 );
297 }
298
299 /**
300 * Update a cache mode string, applying the cache mode of a new module to it.
301 * The cache mode may increase in the level of privacy, but public modules
302 * added to private data do not decrease the level of privacy.
303 *
304 * @param string $cacheMode
305 * @param string $modCacheMode
306 * @return string
307 */
308 protected function mergeCacheMode( $cacheMode, $modCacheMode ) {
309 if ( $modCacheMode === 'anon-public-user-private' ) {
310 if ( $cacheMode !== 'private' ) {
311 $cacheMode = 'anon-public-user-private';
312 }
313 } elseif ( $modCacheMode === 'public' ) {
314 // do nothing, if it's public already it will stay public
315 } else { // private
316 $cacheMode = 'private';
317 }
318
319 return $cacheMode;
320 }
321
322 /**
323 * Create instances of all modules requested by the client
324 * @param array $modules To append instantiated modules to
325 * @param string $param Parameter name to read modules from
326 */
327 private function instantiateModules( &$modules, $param ) {
328 $wasPosted = $this->getRequest()->wasPosted();
329 if ( isset( $this->mParams[$param] ) ) {
330 foreach ( $this->mParams[$param] as $moduleName ) {
331 $instance = $this->mModuleMgr->getModule( $moduleName, $param );
332 if ( $instance === null ) {
333 ApiBase::dieDebug( __METHOD__, 'Error instantiating module' );
334 }
335 if ( !$wasPosted && $instance->mustBePosted() ) {
336 $this->dieUsageMsgOrDebug( array( 'mustbeposted', $moduleName ) );
337 }
338 // Ignore duplicates. TODO 2.0: die()?
339 if ( !array_key_exists( $moduleName, $modules ) ) {
340 $modules[$moduleName] = $instance;
341 }
342 }
343 }
344 }
345
346 /**
347 * Appends an element for each page in the current pageSet with the
348 * most general information (id, title), plus any title normalizations
349 * and missing or invalid title/pageids/revids.
350 */
351 private function outputGeneralPageInfo() {
352 $pageSet = $this->getPageSet();
353 $result = $this->getResult();
354
355 // We can't really handle max-result-size failure here, but we need to
356 // check anyway in case someone set the limit stupidly low.
357 $fit = true;
358
359 $values = $pageSet->getNormalizedTitlesAsResult( $result );
360 if ( $values ) {
361 $fit = $fit && $result->addValue( 'query', 'normalized', $values );
362 }
363 $values = $pageSet->getConvertedTitlesAsResult( $result );
364 if ( $values ) {
365 $fit = $fit && $result->addValue( 'query', 'converted', $values );
366 }
367 $values = $pageSet->getInterwikiTitlesAsResult( $result, $this->mParams['iwurl'] );
368 if ( $values ) {
369 $fit = $fit && $result->addValue( 'query', 'interwiki', $values );
370 }
371 $values = $pageSet->getRedirectTitlesAsResult( $result );
372 if ( $values ) {
373 $fit = $fit && $result->addValue( 'query', 'redirects', $values );
374 }
375 $values = $pageSet->getMissingRevisionIDsAsResult( $result );
376 if ( $values ) {
377 $fit = $fit && $result->addValue( 'query', 'badrevids', $values );
378 }
379
380 // Page elements
381 $pages = array();
382
383 // Report any missing titles
384 foreach ( $pageSet->getMissingTitles() as $fakeId => $title ) {
385 $vals = array();
386 ApiQueryBase::addTitleInfo( $vals, $title );
387 $vals['missing'] = '';
388 $pages[$fakeId] = $vals;
389 }
390 // Report any invalid titles
391 foreach ( $pageSet->getInvalidTitles() as $fakeId => $title ) {
392 $pages[$fakeId] = array( 'title' => $title, 'invalid' => '' );
393 }
394 // Report any missing page ids
395 foreach ( $pageSet->getMissingPageIDs() as $pageid ) {
396 $pages[$pageid] = array(
397 'pageid' => $pageid,
398 'missing' => ''
399 );
400 }
401 // Report special pages
402 /** @var $title Title */
403 foreach ( $pageSet->getSpecialTitles() as $fakeId => $title ) {
404 $vals = array();
405 ApiQueryBase::addTitleInfo( $vals, $title );
406 $vals['special'] = '';
407 if ( $title->isSpecialPage() &&
408 !SpecialPageFactory::exists( $title->getDBkey() )
409 ) {
410 $vals['missing'] = '';
411 } elseif ( $title->getNamespace() == NS_MEDIA &&
412 !wfFindFile( $title )
413 ) {
414 $vals['missing'] = '';
415 }
416 $pages[$fakeId] = $vals;
417 }
418
419 // Output general page information for found titles
420 foreach ( $pageSet->getGoodTitles() as $pageid => $title ) {
421 $vals = array();
422 $vals['pageid'] = $pageid;
423 ApiQueryBase::addTitleInfo( $vals, $title );
424 $pages[$pageid] = $vals;
425 }
426
427 if ( count( $pages ) ) {
428 if ( $this->mParams['indexpageids'] ) {
429 $pageIDs = array_keys( $pages );
430 // json treats all map keys as strings - converting to match
431 $pageIDs = array_map( 'strval', $pageIDs );
432 $result->setIndexedTagName( $pageIDs, 'id' );
433 $fit = $fit && $result->addValue( 'query', 'pageids', $pageIDs );
434 }
435
436 $result->setIndexedTagName( $pages, 'page' );
437 $fit = $fit && $result->addValue( 'query', 'pages', $pages );
438 }
439
440 if ( !$fit ) {
441 $this->dieUsage(
442 'The value of $wgAPIMaxResultSize on this wiki is ' .
443 'too small to hold basic result information',
444 'badconfig'
445 );
446 }
447
448 if ( $this->mParams['export'] ) {
449 $this->doExport( $pageSet, $result );
450 }
451 }
452
453 /**
454 * This method is called by the generator base when generator in the smart-continue
455 * mode tries to set 'query-continue' value. ApiQuery stores those values separately
456 * until the post-processing when it is known if the generation should continue or repeat.
457 * @deprecated since 1.24
458 * @param ApiQueryGeneratorBase $module Generator module
459 * @param string $paramName
460 * @param mixed $paramValue
461 * @return bool True if processed, false if this is a legacy continue
462 */
463 public function setGeneratorContinue( $module, $paramName, $paramValue ) {
464 wfDeprecated( __METHOD__, '1.24' );
465 $this->getResult()->setGeneratorContinueParam( $module, $paramName, $paramValue );
466 return $this->getParameter( 'continue' ) !== null;
467 }
468
469 /**
470 * @param ApiPageSet $pageSet Pages to be exported
471 * @param ApiResult $result Result to output to
472 */
473 private function doExport( $pageSet, $result ) {
474 $exportTitles = array();
475 $titles = $pageSet->getGoodTitles();
476 if ( count( $titles ) ) {
477 $user = $this->getUser();
478 /** @var $title Title */
479 foreach ( $titles as $title ) {
480 if ( $title->userCan( 'read', $user ) ) {
481 $exportTitles[] = $title;
482 }
483 }
484 }
485
486 $exporter = new WikiExporter( $this->getDB() );
487 // WikiExporter writes to stdout, so catch its
488 // output with an ob
489 ob_start();
490 $exporter->openStream();
491 foreach ( $exportTitles as $title ) {
492 $exporter->pageByTitle( $title );
493 }
494 $exporter->closeStream();
495 $exportxml = ob_get_contents();
496 ob_end_clean();
497
498 // Don't check the size of exported stuff
499 // It's not continuable, so it would cause more
500 // problems than it'd solve
501 if ( $this->mParams['exportnowrap'] ) {
502 $result->reset();
503 // Raw formatter will handle this
504 $result->addValue( null, 'text', $exportxml, ApiResult::NO_SIZE_CHECK );
505 $result->addValue( null, 'mime', 'text/xml', ApiResult::NO_SIZE_CHECK );
506 } else {
507 $r = array();
508 ApiResult::setContent( $r, $exportxml );
509 $result->addValue( 'query', 'export', $r, ApiResult::NO_SIZE_CHECK );
510 }
511 }
512
513 public function getAllowedParams( $flags = 0 ) {
514 $result = array(
515 'prop' => array(
516 ApiBase::PARAM_ISMULTI => true,
517 ApiBase::PARAM_TYPE => 'submodule',
518 ),
519 'list' => array(
520 ApiBase::PARAM_ISMULTI => true,
521 ApiBase::PARAM_TYPE => 'submodule',
522 ),
523 'meta' => array(
524 ApiBase::PARAM_ISMULTI => true,
525 ApiBase::PARAM_TYPE => 'submodule',
526 ),
527 'indexpageids' => false,
528 'export' => false,
529 'exportnowrap' => false,
530 'iwurl' => false,
531 'continue' => null,
532 'rawcontinue' => false,
533 );
534 if ( $flags ) {
535 $result += $this->getPageSet()->getFinalParams( $flags );
536 }
537
538 return $result;
539 }
540
541 /**
542 * Override the parent to generate help messages for all available query modules.
543 * @return string
544 */
545 public function makeHelpMsg() {
546
547 // Use parent to make default message for the query module
548 $msg = parent::makeHelpMsg();
549
550 $querySeparator = str_repeat( '--- ', 12 );
551 $moduleSeparator = str_repeat( '*** ', 14 );
552 $msg .= "\n$querySeparator Query: Prop $querySeparator\n\n";
553 $msg .= $this->makeHelpMsgHelper( 'prop' );
554 $msg .= "\n$querySeparator Query: List $querySeparator\n\n";
555 $msg .= $this->makeHelpMsgHelper( 'list' );
556 $msg .= "\n$querySeparator Query: Meta $querySeparator\n\n";
557 $msg .= $this->makeHelpMsgHelper( 'meta' );
558 $msg .= "\n\n$moduleSeparator Modules: continuation $moduleSeparator\n\n";
559
560 return $msg;
561 }
562
563 /**
564 * For all modules of a given group, generate help messages and join them together
565 * @param string $group Module group
566 * @return string
567 */
568 private function makeHelpMsgHelper( $group ) {
569 $moduleDescriptions = array();
570
571 $moduleNames = $this->mModuleMgr->getNames( $group );
572 sort( $moduleNames );
573 foreach ( $moduleNames as $name ) {
574 /**
575 * @var $module ApiQueryBase
576 */
577 $module = $this->mModuleMgr->getModule( $name );
578
579 $msg = ApiMain::makeHelpMsgHeader( $module, $group );
580 $msg2 = $module->makeHelpMsg();
581 if ( $msg2 !== false ) {
582 $msg .= $msg2;
583 }
584 if ( $module instanceof ApiQueryGeneratorBase ) {
585 $msg .= "Generator:\n This module may be used as a generator\n";
586 }
587 $moduleDescriptions[] = $msg;
588 }
589
590 return implode( "\n", $moduleDescriptions );
591 }
592
593 public function shouldCheckMaxlag() {
594 return true;
595 }
596
597 public function getParamDescription() {
598 return $this->getPageSet()->getFinalParamDescription() + array(
599 'prop' => 'Which properties to get for the titles/revisions/pageids. ' .
600 'Module help is available below',
601 'list' => 'Which lists to get. Module help is available below',
602 'meta' => 'Which metadata to get about the site. Module help is available below',
603 'indexpageids' => 'Include an additional pageids section listing all returned page IDs',
604 'export' => 'Export the current revisions of all given or generated pages',
605 'exportnowrap' => 'Return the export XML without wrapping it in an ' .
606 'XML result (same format as Special:Export). Can only be used with export',
607 'iwurl' => 'Whether to get the full URL if the title is an interwiki link',
608 'continue' => array(
609 'When present, formats query-continue as key-value pairs that ' .
610 'should simply be merged into the original request.',
611 'This parameter must be set to an empty string in the initial query.',
612 'This parameter is recommended for all new development, and ' .
613 'will be made default in the next API version.'
614 ),
615 'rawcontinue' => 'Currently ignored. In the future, \'continue=\' will become the ' .
616 'default and this will be needed to receive the raw query-continue data.',
617 );
618 }
619
620 public function getDescription() {
621 return array(
622 'Query API module allows applications to get needed pieces of data ' .
623 'from the MediaWiki databases,',
624 'and is loosely based on the old query.php interface.',
625 'All data modifications will first have to use query to acquire a ' .
626 'token to prevent abuse from malicious sites.'
627 );
628 }
629
630 public function getExamples() {
631 return array(
632 'api.php?action=query&prop=revisions&meta=siteinfo&' .
633 'titles=Main%20Page&rvprop=user|comment&continue=',
634 'api.php?action=query&generator=allpages&gapprefix=API/&prop=revisions&continue=',
635 );
636 }
637
638 public function getHelpUrls() {
639 return array(
640 'https://www.mediawiki.org/wiki/API:Query',
641 'https://www.mediawiki.org/wiki/API:Meta',
642 'https://www.mediawiki.org/wiki/API:Properties',
643 'https://www.mediawiki.org/wiki/API:Lists',
644 );
645 }
646 }