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 * @defgroup API API 22 */ 23 24use MediaWiki\Api\Validator\ApiParamValidator; 25use MediaWiki\Logger\LoggerFactory; 26use MediaWiki\MediaWikiServices; 27use MediaWiki\ParamValidator\TypeDef\UserDef; 28use MediaWiki\Session\SessionManager; 29use Wikimedia\Timestamp\TimestampException; 30 31/** 32 * This is the main API class, used for both external and internal processing. 33 * When executed, it will create the requested formatter object, 34 * instantiate and execute an object associated with the needed action, 35 * and use formatter to print results. 36 * In case of an exception, an error message will be printed using the same formatter. 37 * 38 * To use API from another application, run it using FauxRequest object, in which 39 * case any internal exceptions will not be handled but passed up to the caller. 40 * After successful execution, use getResult() for the resulting data. 41 * 42 * @newable 43 * @note marked as newable in 1.35 for lack of a better alternative, 44 * but should use a factory in the future. 45 * @ingroup API 46 */ 47class ApiMain extends ApiBase { 48 /** 49 * When no format parameter is given, this format will be used 50 */ 51 private const API_DEFAULT_FORMAT = 'jsonfm'; 52 53 /** 54 * When no uselang parameter is given, this language will be used 55 */ 56 private const API_DEFAULT_USELANG = 'user'; 57 58 /** 59 * List of available modules: action name => module class 60 */ 61 private static $Modules = [ 62 'login' => ApiLogin::class, 63 'clientlogin' => ApiClientLogin::class, 64 'logout' => ApiLogout::class, 65 'createaccount' => ApiAMCreateAccount::class, 66 'linkaccount' => ApiLinkAccount::class, 67 'unlinkaccount' => ApiRemoveAuthenticationData::class, 68 'changeauthenticationdata' => ApiChangeAuthenticationData::class, 69 'removeauthenticationdata' => ApiRemoveAuthenticationData::class, 70 'resetpassword' => ApiResetPassword::class, 71 'query' => ApiQuery::class, 72 'expandtemplates' => ApiExpandTemplates::class, 73 'parse' => ApiParse::class, 74 'stashedit' => ApiStashEdit::class, 75 'opensearch' => ApiOpenSearch::class, 76 'feedcontributions' => ApiFeedContributions::class, 77 'feedrecentchanges' => ApiFeedRecentChanges::class, 78 'feedwatchlist' => ApiFeedWatchlist::class, 79 'help' => ApiHelp::class, 80 'paraminfo' => ApiParamInfo::class, 81 'rsd' => ApiRsd::class, 82 'compare' => ApiComparePages::class, 83 'tokens' => ApiTokens::class, 84 'checktoken' => ApiCheckToken::class, 85 'cspreport' => ApiCSPReport::class, 86 'validatepassword' => ApiValidatePassword::class, 87 88 // Write modules 89 'purge' => ApiPurge::class, 90 'setnotificationtimestamp' => ApiSetNotificationTimestamp::class, 91 'rollback' => ApiRollback::class, 92 'delete' => ApiDelete::class, 93 'undelete' => ApiUndelete::class, 94 'protect' => ApiProtect::class, 95 'block' => ApiBlock::class, 96 'unblock' => ApiUnblock::class, 97 'move' => ApiMove::class, 98 'edit' => ApiEditPage::class, 99 'upload' => ApiUpload::class, 100 'filerevert' => ApiFileRevert::class, 101 'emailuser' => ApiEmailUser::class, 102 'watch' => ApiWatch::class, 103 'patrol' => ApiPatrol::class, 104 'import' => ApiImport::class, 105 'clearhasmsg' => ApiClearHasMsg::class, 106 'userrights' => ApiUserrights::class, 107 'options' => ApiOptions::class, 108 'imagerotate' => ApiImageRotate::class, 109 'revisiondelete' => ApiRevisionDelete::class, 110 'managetags' => ApiManageTags::class, 111 'tag' => ApiTag::class, 112 'mergehistory' => ApiMergeHistory::class, 113 'setpagelanguage' => ApiSetPageLanguage::class, 114 'changecontentmodel' => ApiChangeContentModel::class, 115 ]; 116 117 /** 118 * List of available formats: format name => format class 119 */ 120 private static $Formats = [ 121 'json' => ApiFormatJson::class, 122 'jsonfm' => ApiFormatJson::class, 123 'php' => ApiFormatPhp::class, 124 'phpfm' => ApiFormatPhp::class, 125 'xml' => ApiFormatXml::class, 126 'xmlfm' => ApiFormatXml::class, 127 'rawfm' => ApiFormatJson::class, 128 'none' => ApiFormatNone::class, 129 ]; 130 131 /** 132 * List of user roles that are specifically relevant to the API. 133 * [ 'right' => [ 'msg' => 'Some message with a $1', 134 * 'params' => [ $someVarToSubst ] ], 135 * ]; 136 */ 137 private static $mRights = [ 138 'writeapi' => [ 139 'msg' => 'right-writeapi', 140 'params' => [] 141 ], 142 'apihighlimits' => [ 143 'msg' => 'api-help-right-apihighlimits', 144 'params' => [ ApiBase::LIMIT_SML2, ApiBase::LIMIT_BIG2 ] 145 ] 146 ]; 147 148 /** 149 * @var ApiFormatBase 150 */ 151 private $mPrinter; 152 153 private $mModuleMgr, $mResult, $mErrorFormatter = null, $mParamValidator; 154 /** @var ApiContinuationManager|null */ 155 private $mContinuationManager; 156 private $mAction; 157 private $mEnableWrite; 158 private $mInternalMode, $mCdnMaxAge; 159 /** @var ApiBase */ 160 private $mModule; 161 162 private $mCacheMode = 'private'; 163 /** @var array */ 164 private $mCacheControl = []; 165 private $mParamsUsed = []; 166 private $mParamsSensitive = []; 167 168 /** @var bool|null Cached return value from self::lacksSameOriginSecurity() */ 169 private $lacksSameOriginSecurity = null; 170 171 /** 172 * Constructs an instance of ApiMain that utilizes the module and format specified by $request. 173 * 174 * @stable to call 175 * @param IContextSource|WebRequest|null $context If this is an instance of 176 * FauxRequest, errors are thrown and no printing occurs 177 * @param bool $enableWrite Should be set to true if the api may modify data 178 */ 179 public function __construct( $context = null, $enableWrite = false ) { 180 if ( $context === null ) { 181 $context = RequestContext::getMain(); 182 } elseif ( $context instanceof WebRequest ) { 183 // BC for pre-1.19 184 $request = $context; 185 $context = RequestContext::getMain(); 186 } 187 // We set a derivative context so we can change stuff later 188 $derivativeContext = new DerivativeContext( $context ); 189 $this->setContext( $derivativeContext ); 190 191 if ( isset( $request ) ) { 192 $derivativeContext->setRequest( $request ); 193 } else { 194 $request = $this->getRequest(); 195 } 196 197 $this->mInternalMode = ( $request instanceof FauxRequest ); 198 199 // Special handling for the main module: $parent === $this 200 parent::__construct( $this, $this->mInternalMode ? 'main_int' : 'main' ); 201 202 $config = $this->getConfig(); 203 204 if ( !$this->mInternalMode ) { 205 // If we're in a mode that breaks the same-origin policy, strip 206 // user credentials for security. 207 if ( $this->lacksSameOriginSecurity() ) { 208 global $wgUser; 209 wfDebug( "API: stripping user credentials when the same-origin policy is not applied" ); 210 $user = new User(); 211 $wgUser = $user; 212 $derivativeContext->setUser( $user ); 213 $request->response()->header( 'MediaWiki-Login-Suppressed: true' ); 214 } 215 } 216 217 $this->mParamValidator = new ApiParamValidator( 218 $this, MediaWikiServices::getInstance()->getObjectFactory() 219 ); 220 221 $this->mResult = new ApiResult( $this->getConfig()->get( 'APIMaxResultSize' ) ); 222 223 // Setup uselang. This doesn't use $this->getParameter() 224 // because we're not ready to handle errors yet. 225 // Optimisation: Avoid slow getVal(), this isn't user-generated content. 226 $uselang = $request->getRawVal( 'uselang', self::API_DEFAULT_USELANG ); 227 if ( $uselang === 'user' ) { 228 // Assume the parent context is going to return the user language 229 // for uselang=user (see T85635). 230 } else { 231 if ( $uselang === 'content' ) { 232 $uselang = MediaWikiServices::getInstance()->getContentLanguage()->getCode(); 233 } 234 $code = RequestContext::sanitizeLangCode( $uselang ); 235 $derivativeContext->setLanguage( $code ); 236 if ( !$this->mInternalMode ) { 237 global $wgLang; 238 $wgLang = $derivativeContext->getLanguage(); 239 RequestContext::getMain()->setLanguage( $wgLang ); 240 } 241 } 242 243 // Set up the error formatter. This doesn't use $this->getParameter() 244 // because we're not ready to handle errors yet. 245 // Optimisation: Avoid slow getVal(), this isn't user-generated content. 246 $errorFormat = $request->getRawVal( 'errorformat', 'bc' ); 247 $errorLangCode = $request->getRawVal( 'errorlang', 'uselang' ); 248 $errorsUseDB = $request->getCheck( 'errorsuselocal' ); 249 if ( in_array( $errorFormat, [ 'plaintext', 'wikitext', 'html', 'raw', 'none' ], true ) ) { 250 if ( $errorLangCode === 'uselang' ) { 251 $errorLang = $this->getLanguage(); 252 } elseif ( $errorLangCode === 'content' ) { 253 $errorLang = MediaWikiServices::getInstance()->getContentLanguage(); 254 } else { 255 $errorLangCode = RequestContext::sanitizeLangCode( $errorLangCode ); 256 $errorLang = MediaWikiServices::getInstance()->getLanguageFactory() 257 ->getLanguage( $errorLangCode ); 258 } 259 $this->mErrorFormatter = new ApiErrorFormatter( 260 $this->mResult, $errorLang, $errorFormat, $errorsUseDB 261 ); 262 } else { 263 $this->mErrorFormatter = new ApiErrorFormatter_BackCompat( $this->mResult ); 264 } 265 $this->mResult->setErrorFormatter( $this->getErrorFormatter() ); 266 267 $this->mModuleMgr = new ApiModuleManager( 268 $this, 269 MediaWikiServices::getInstance()->getObjectFactory() 270 ); 271 $this->mModuleMgr->addModules( self::$Modules, 'action' ); 272 $this->mModuleMgr->addModules( $config->get( 'APIModules' ), 'action' ); 273 $this->mModuleMgr->addModules( self::$Formats, 'format' ); 274 $this->mModuleMgr->addModules( $config->get( 'APIFormatModules' ), 'format' ); 275 276 $this->getHookRunner()->onApiMain__moduleManager( $this->mModuleMgr ); 277 278 $this->mContinuationManager = null; 279 $this->mEnableWrite = $enableWrite; 280 281 $this->mCdnMaxAge = -1; // flag for executeActionWithErrorHandling() 282 } 283 284 /** 285 * Return true if the API was started by other PHP code using FauxRequest 286 * @return bool 287 */ 288 public function isInternalMode() { 289 return $this->mInternalMode; 290 } 291 292 /** 293 * Get the ApiResult object associated with current request 294 * 295 * @return ApiResult 296 */ 297 public function getResult() { 298 return $this->mResult; 299 } 300 301 /** 302 * Get the security flag for the current request 303 * @return bool 304 */ 305 public function lacksSameOriginSecurity() { 306 if ( $this->lacksSameOriginSecurity !== null ) { 307 return $this->lacksSameOriginSecurity; 308 } 309 310 $request = $this->getRequest(); 311 312 // JSONP mode 313 if ( $request->getCheck( 'callback' ) ) { 314 $this->lacksSameOriginSecurity = true; 315 return true; 316 } 317 318 // Anonymous CORS 319 if ( $request->getVal( 'origin' ) === '*' ) { 320 $this->lacksSameOriginSecurity = true; 321 return true; 322 } 323 324 // Header to be used from XMLHTTPRequest when the request might 325 // otherwise be used for XSS. 326 if ( $request->getHeader( 'Treat-as-Untrusted' ) !== false ) { 327 $this->lacksSameOriginSecurity = true; 328 return true; 329 } 330 331 // Allow extensions to override. 332 $this->lacksSameOriginSecurity = !$this->getHookRunner() 333 ->onRequestHasSameOriginSecurity( $request ); 334 return $this->lacksSameOriginSecurity; 335 } 336 337 /** 338 * Get the ApiErrorFormatter object associated with current request 339 * @return ApiErrorFormatter 340 */ 341 public function getErrorFormatter() { 342 return $this->mErrorFormatter; 343 } 344 345 /** 346 * Get the continuation manager 347 * @return ApiContinuationManager|null 348 */ 349 public function getContinuationManager() { 350 return $this->mContinuationManager; 351 } 352 353 /** 354 * Set the continuation manager 355 * @param ApiContinuationManager|null $manager 356 */ 357 public function setContinuationManager( ApiContinuationManager $manager = null ) { 358 if ( $manager !== null && $this->mContinuationManager !== null ) { 359 throw new UnexpectedValueException( 360 __METHOD__ . ': tried to set manager from ' . $manager->getSource() . 361 ' when a manager is already set from ' . $this->mContinuationManager->getSource() 362 ); 363 } 364 $this->mContinuationManager = $manager; 365 } 366 367 /** 368 * Get the parameter validator 369 * @return ApiParamValidator 370 */ 371 public function getParamValidator() : ApiParamValidator { 372 return $this->mParamValidator; 373 } 374 375 /** 376 * Get the API module object. Only works after executeAction() 377 * 378 * @return ApiBase 379 */ 380 public function getModule() { 381 return $this->mModule; 382 } 383 384 /** 385 * Get the result formatter object. Only works after setupExecuteAction() 386 * 387 * @return ApiFormatBase 388 */ 389 public function getPrinter() { 390 return $this->mPrinter; 391 } 392 393 /** 394 * Set how long the response should be cached. 395 * 396 * @param int $maxage 397 */ 398 public function setCacheMaxAge( $maxage ) { 399 $this->setCacheControl( [ 400 'max-age' => $maxage, 401 's-maxage' => $maxage 402 ] ); 403 } 404 405 /** 406 * Set the type of caching headers which will be sent. 407 * 408 * @param string $mode One of: 409 * - 'public': Cache this object in public caches, if the maxage or smaxage 410 * parameter is set, or if setCacheMaxAge() was called. If a maximum age is 411 * not provided by any of these means, the object will be private. 412 * - 'private': Cache this object only in private client-side caches. 413 * - 'anon-public-user-private': Make this object cacheable for logged-out 414 * users, but private for logged-in users. IMPORTANT: If this is set, it must be 415 * set consistently for a given URL, it cannot be set differently depending on 416 * things like the contents of the database, or whether the user is logged in. 417 * 418 * If the wiki does not allow anonymous users to read it, the mode set here 419 * will be ignored, and private caching headers will always be sent. In other words, 420 * the "public" mode is equivalent to saying that the data sent is as public as a page 421 * view. 422 * 423 * For user-dependent data, the private mode should generally be used. The 424 * anon-public-user-private mode should only be used where there is a particularly 425 * good performance reason for caching the anonymous response, but where the 426 * response to logged-in users may differ, or may contain private data. 427 * 428 * If this function is never called, then the default will be the private mode. 429 */ 430 public function setCacheMode( $mode ) { 431 if ( !in_array( $mode, [ 'private', 'public', 'anon-public-user-private' ] ) ) { 432 wfDebug( __METHOD__ . ": unrecognised cache mode \"$mode\"" ); 433 434 // Ignore for forwards-compatibility 435 return; 436 } 437 438 if ( !$this->getPermissionManager()->isEveryoneAllowed( 'read' ) ) { 439 // Private wiki, only private headers 440 if ( $mode !== 'private' ) { 441 wfDebug( __METHOD__ . ": ignoring request for $mode cache mode, private wiki" ); 442 443 return; 444 } 445 } 446 447 if ( $mode === 'public' && $this->getParameter( 'uselang' ) === 'user' ) { 448 // User language is used for i18n, so we don't want to publicly 449 // cache. Anons are ok, because if they have non-default language 450 // then there's an appropriate Vary header set by whatever set 451 // their non-default language. 452 wfDebug( __METHOD__ . ": downgrading cache mode 'public' to " . 453 "'anon-public-user-private' due to uselang=user" ); 454 $mode = 'anon-public-user-private'; 455 } 456 457 wfDebug( __METHOD__ . ": setting cache mode $mode" ); 458 $this->mCacheMode = $mode; 459 } 460 461 /** 462 * Set directives (key/value pairs) for the Cache-Control header. 463 * Boolean values will be formatted as such, by including or omitting 464 * without an equals sign. 465 * 466 * Cache control values set here will only be used if the cache mode is not 467 * private, see setCacheMode(). 468 * 469 * @param array $directives 470 */ 471 public function setCacheControl( $directives ) { 472 $this->mCacheControl = $directives + $this->mCacheControl; 473 } 474 475 /** 476 * Create an instance of an output formatter by its name 477 * 478 * @param string $format 479 * 480 * @return ApiFormatBase 481 */ 482 public function createPrinterByName( $format ) { 483 $printer = $this->mModuleMgr->getModule( $format, 'format', /* $ignoreCache */ true ); 484 if ( $printer === null ) { 485 $this->dieWithError( 486 [ 'apierror-unknownformat', wfEscapeWikiText( $format ) ], 'unknown_format' 487 ); 488 } 489 490 return $printer; 491 } 492 493 /** 494 * Execute api request. Any errors will be handled if the API was called by the remote client. 495 */ 496 public function execute() { 497 if ( $this->mInternalMode ) { 498 $this->executeAction(); 499 } else { 500 $this->executeActionWithErrorHandling(); 501 } 502 } 503 504 /** 505 * Execute an action, and in case of an error, erase whatever partial results 506 * have been accumulated, and replace it with an error message and a help screen. 507 */ 508 protected function executeActionWithErrorHandling() { 509 // Verify the CORS header before executing the action 510 if ( !$this->handleCORS() ) { 511 // handleCORS() has sent a 403, abort 512 return; 513 } 514 515 // Exit here if the request method was OPTIONS 516 // (assume there will be a followup GET or POST) 517 if ( $this->getRequest()->getMethod() === 'OPTIONS' ) { 518 return; 519 } 520 521 // In case an error occurs during data output, 522 // clear the output buffer and print just the error information 523 $obLevel = ob_get_level(); 524 ob_start(); 525 526 $t = microtime( true ); 527 $isError = false; 528 try { 529 $this->executeAction(); 530 $runTime = microtime( true ) - $t; 531 $this->logRequest( $runTime ); 532 MediaWikiServices::getInstance()->getStatsdDataFactory()->timing( 533 'api.' . $this->mModule->getModuleName() . '.executeTiming', 1000 * $runTime 534 ); 535 } catch ( Throwable $e ) { 536 $this->handleException( $e ); 537 $this->logRequest( microtime( true ) - $t, $e ); 538 $isError = true; 539 } 540 541 // Disable the client cache on the output so that BlockManager::trackBlockWithCookie is executed 542 // as part of MediaWiki::preOutputCommit(). 543 if ( 544 $this->mCacheMode === 'private' 545 || ( 546 $this->mCacheMode === 'anon-public-user-private' 547 && SessionManager::getGlobalSession()->isPersistent() 548 ) 549 ) { 550 $this->getContext()->getOutput()->enableClientCache( false ); 551 $this->getContext()->getOutput()->considerCacheSettingsFinal(); 552 } 553 554 // Commit DBs and send any related cookies and headers 555 MediaWiki::preOutputCommit( $this->getContext() ); 556 557 // Send cache headers after any code which might generate an error, to 558 // avoid sending public cache headers for errors. 559 $this->sendCacheHeaders( $isError ); 560 561 // Executing the action might have already messed with the output 562 // buffers. 563 while ( ob_get_level() > $obLevel ) { 564 ob_end_flush(); 565 } 566 } 567 568 /** 569 * Handle a throwable as an API response 570 * 571 * @since 1.23 572 * @param Throwable $e 573 */ 574 protected function handleException( Throwable $e ) { 575 // T65145: Rollback any open database transactions 576 if ( !$e instanceof ApiUsageException ) { 577 // ApiUsageExceptions are intentional, so don't rollback if that's the case 578 MWExceptionHandler::rollbackMasterChangesAndLog( 579 $e, 580 MWExceptionHandler::CAUGHT_BY_ENTRYPOINT 581 ); 582 } 583 584 // Allow extra cleanup and logging 585 $this->getHookRunner()->onApiMain__onException( $this, $e ); 586 587 // Handle any kind of exception by outputting properly formatted error message. 588 // If this fails, an unhandled exception should be thrown so that global error 589 // handler will process and log it. 590 591 $errCodes = $this->substituteResultWithError( $e ); 592 593 // Error results should not be cached 594 $this->setCacheMode( 'private' ); 595 596 $response = $this->getRequest()->response(); 597 $headerStr = 'MediaWiki-API-Error: ' . implode( ', ', $errCodes ); 598 $response->header( $headerStr ); 599 600 // Reset and print just the error message 601 ob_clean(); 602 603 // Printer may not be initialized if the extractRequestParams() fails for the main module 604 $this->createErrorPrinter(); 605 606 // Get desired HTTP code from an ApiUsageException. Don't use codes from other 607 // exception types, as they are unlikely to be intended as an HTTP code. 608 $httpCode = $e instanceof ApiUsageException ? $e->getCode() : 0; 609 610 $failed = false; 611 try { 612 $this->printResult( $httpCode ); 613 } catch ( ApiUsageException $ex ) { 614 // The error printer itself is failing. Try suppressing its request 615 // parameters and redo. 616 $failed = true; 617 $this->addWarning( 'apiwarn-errorprinterfailed' ); 618 foreach ( $ex->getStatusValue()->getErrors() as $error ) { 619 try { 620 $this->mPrinter->addWarning( $error ); 621 } catch ( Throwable $ex2 ) { 622 // WTF? 623 $this->addWarning( $error ); 624 } 625 } 626 } 627 if ( $failed ) { 628 $this->mPrinter = null; 629 $this->createErrorPrinter(); 630 $this->mPrinter->forceDefaultParams(); 631 if ( $httpCode ) { 632 $response->statusHeader( 200 ); // Reset in case the fallback doesn't want a non-200 633 } 634 $this->printResult( $httpCode ); 635 } 636 } 637 638 /** 639 * Handle a throwable from the ApiBeforeMain hook. 640 * 641 * This tries to print the throwable as an API response, to be more 642 * friendly to clients. If it fails, it will rethrow the throwable. 643 * 644 * @since 1.23 645 * @param Throwable $e 646 * @throws Throwable 647 */ 648 public static function handleApiBeforeMainException( Throwable $e ) { 649 ob_start(); 650 651 try { 652 $main = new self( RequestContext::getMain(), false ); 653 $main->handleException( $e ); 654 $main->logRequest( 0, $e ); 655 } catch ( Throwable $e2 ) { 656 // Nope, even that didn't work. Punt. 657 throw $e; 658 } 659 660 // Reset cache headers 661 $main->sendCacheHeaders( true ); 662 663 ob_end_flush(); 664 } 665 666 /** 667 * Check the &origin= query parameter against the Origin: HTTP header and respond appropriately. 668 * 669 * If no origin parameter is present, nothing happens. 670 * If an origin parameter is present but doesn't match the Origin header, a 403 status code 671 * is set and false is returned. 672 * If the parameter and the header do match, the header is checked against $wgCrossSiteAJAXdomains 673 * and $wgCrossSiteAJAXdomainExceptions, and if the origin qualifies, the appropriate CORS 674 * headers are set. 675 * https://www.w3.org/TR/cors/#resource-requests 676 * https://www.w3.org/TR/cors/#resource-preflight-requests 677 * 678 * @return bool False if the caller should abort (403 case), true otherwise (all other cases) 679 */ 680 protected function handleCORS() { 681 $originParam = $this->getParameter( 'origin' ); // defaults to null 682 if ( $originParam === null ) { 683 // No origin parameter, nothing to do 684 return true; 685 } 686 687 $request = $this->getRequest(); 688 $response = $request->response(); 689 690 $allowTiming = false; 691 $varyOrigin = true; 692 693 if ( $originParam === '*' ) { 694 // Request for anonymous CORS 695 // Technically we should check for the presence of an Origin header 696 // and not process it as CORS if it's not set, but that would 697 // require us to vary on Origin for all 'origin=*' requests which 698 // we don't want to do. 699 $matchedOrigin = true; 700 $allowOrigin = '*'; 701 $allowCredentials = 'false'; 702 $varyOrigin = false; // No need to vary 703 } else { 704 // Non-anonymous CORS, check we allow the domain 705 706 // Origin: header is a space-separated list of origins, check all of them 707 $originHeader = $request->getHeader( 'Origin' ); 708 if ( $originHeader === false ) { 709 $origins = []; 710 } else { 711 $originHeader = trim( $originHeader ); 712 $origins = preg_split( '/\s+/', $originHeader ); 713 } 714 715 if ( !in_array( $originParam, $origins ) ) { 716 // origin parameter set but incorrect 717 // Send a 403 response 718 $response->statusHeader( 403 ); 719 $response->header( 'Cache-Control: no-cache' ); 720 echo "'origin' parameter does not match Origin header\n"; 721 722 return false; 723 } 724 725 $config = $this->getConfig(); 726 $matchedOrigin = count( $origins ) === 1 && self::matchOrigin( 727 $originParam, 728 $config->get( 'CrossSiteAJAXdomains' ), 729 $config->get( 'CrossSiteAJAXdomainExceptions' ) 730 ); 731 732 $allowOrigin = $originHeader; 733 $allowCredentials = 'true'; 734 $allowTiming = $originHeader; 735 } 736 737 if ( $matchedOrigin ) { 738 $requestedMethod = $request->getHeader( 'Access-Control-Request-Method' ); 739 $preflight = $request->getMethod() === 'OPTIONS' && $requestedMethod !== false; 740 if ( $preflight ) { 741 // This is a CORS preflight request 742 if ( $requestedMethod !== 'POST' && $requestedMethod !== 'GET' ) { 743 // If method is not a case-sensitive match, do not set any additional headers and terminate. 744 $response->header( 'MediaWiki-CORS-Rejection: Unsupported method requested in preflight' ); 745 return true; 746 } 747 // We allow the actual request to send the following headers 748 $requestedHeaders = $request->getHeader( 'Access-Control-Request-Headers' ); 749 $allowedHeaders = $this->getConfig()->get( 'AllowedCorsHeaders' ); 750 if ( $requestedHeaders !== false ) { 751 if ( !self::matchRequestedHeaders( $requestedHeaders, $allowedHeaders ) ) { 752 $response->header( 'MediaWiki-CORS-Rejection: Unsupported header requested in preflight' ); 753 return true; 754 } 755 $response->header( 'Access-Control-Allow-Headers: ' . $requestedHeaders ); 756 } 757 758 // We only allow the actual request to be GET or POST 759 $response->header( 'Access-Control-Allow-Methods: POST, GET' ); 760 } elseif ( $request->getMethod() !== 'POST' && $request->getMethod() !== 'GET' ) { 761 // Unsupported non-preflight method, don't handle it as CORS 762 $response->header( 763 'MediaWiki-CORS-Rejection: Unsupported method for simple request or actual request' 764 ); 765 return true; 766 } 767 768 $response->header( "Access-Control-Allow-Origin: $allowOrigin" ); 769 $response->header( "Access-Control-Allow-Credentials: $allowCredentials" ); 770 // https://www.w3.org/TR/resource-timing/#timing-allow-origin 771 if ( $allowTiming !== false ) { 772 $response->header( "Timing-Allow-Origin: $allowTiming" ); 773 } 774 775 if ( !$preflight ) { 776 $response->header( 777 'Access-Control-Expose-Headers: MediaWiki-API-Error, Retry-After, X-Database-Lag, ' 778 . 'MediaWiki-Login-Suppressed' 779 ); 780 } 781 } else { 782 $response->header( 'MediaWiki-CORS-Rejection: Origin mismatch' ); 783 } 784 785 if ( $varyOrigin ) { 786 $this->getOutput()->addVaryHeader( 'Origin' ); 787 } 788 789 return true; 790 } 791 792 /** 793 * Attempt to match an Origin header against a set of rules and a set of exceptions 794 * @param string $value Origin header 795 * @param array $rules Set of wildcard rules 796 * @param array $exceptions Set of wildcard rules 797 * @return bool True if $value matches a rule in $rules and doesn't match 798 * any rules in $exceptions, false otherwise 799 */ 800 protected static function matchOrigin( $value, $rules, $exceptions ) { 801 foreach ( $rules as $rule ) { 802 if ( preg_match( self::wildcardToRegex( $rule ), $value ) ) { 803 // Rule matches, check exceptions 804 foreach ( $exceptions as $exc ) { 805 if ( preg_match( self::wildcardToRegex( $exc ), $value ) ) { 806 return false; 807 } 808 } 809 810 return true; 811 } 812 } 813 814 return false; 815 } 816 817 /** 818 * Attempt to validate the value of Access-Control-Request-Headers against a list 819 * of headers that we allow the follow up request to send. 820 * 821 * @param string $requestedHeaders Comma separated list of HTTP headers 822 * @param string[] $allowedHeaders List of allowed HTTP headers 823 * @return bool True if all requested headers are in the list of allowed headers 824 */ 825 protected static function matchRequestedHeaders( $requestedHeaders, $allowedHeaders ) { 826 if ( trim( $requestedHeaders ) === '' ) { 827 return true; 828 } 829 $requestedHeaders = explode( ',', $requestedHeaders ); 830 $allowedHeaders = array_change_key_case( array_flip( $allowedHeaders ), CASE_LOWER ); 831 foreach ( $requestedHeaders as $rHeader ) { 832 $rHeader = strtolower( trim( $rHeader ) ); 833 if ( !isset( $allowedHeaders[$rHeader] ) ) { 834 LoggerFactory::getInstance( 'api-warning' )->warning( 835 'CORS preflight failed on requested header: {header}', [ 836 'header' => $rHeader 837 ] 838 ); 839 return false; 840 } 841 } 842 return true; 843 } 844 845 /** 846 * Helper function to convert wildcard string into a regex 847 * '*' => '.*?' 848 * '?' => '.' 849 * 850 * @param string $wildcard String with wildcards 851 * @return string Regular expression 852 */ 853 protected static function wildcardToRegex( $wildcard ) { 854 $wildcard = preg_quote( $wildcard, '/' ); 855 $wildcard = str_replace( 856 [ '\*', '\?' ], 857 [ '.*?', '.' ], 858 $wildcard 859 ); 860 861 return "/^https?:\/\/$wildcard$/"; 862 } 863 864 /** 865 * Send caching headers 866 * @param bool $isError Whether an error response is being output 867 * @since 1.26 added $isError parameter 868 */ 869 protected function sendCacheHeaders( $isError ) { 870 $response = $this->getRequest()->response(); 871 $out = $this->getOutput(); 872 873 $out->addVaryHeader( 'Treat-as-Untrusted' ); 874 875 $config = $this->getConfig(); 876 877 if ( $config->get( 'VaryOnXFP' ) ) { 878 $out->addVaryHeader( 'X-Forwarded-Proto' ); 879 } 880 881 if ( !$isError && $this->mModule && 882 ( $this->getRequest()->getMethod() === 'GET' || $this->getRequest()->getMethod() === 'HEAD' ) 883 ) { 884 $etag = $this->mModule->getConditionalRequestData( 'etag' ); 885 if ( $etag !== null ) { 886 $response->header( "ETag: $etag" ); 887 } 888 $lastMod = $this->mModule->getConditionalRequestData( 'last-modified' ); 889 if ( $lastMod !== null ) { 890 $response->header( 'Last-Modified: ' . wfTimestamp( TS_RFC2822, $lastMod ) ); 891 } 892 } 893 894 // The logic should be: 895 // $this->mCacheControl['max-age'] is set? 896 // Use it, the module knows better than our guess. 897 // !$this->mModule || $this->mModule->isWriteMode(), and mCacheMode is private? 898 // Use 0 because we can guess caching is probably the wrong thing to do. 899 // Use $this->getParameter( 'maxage' ), which already defaults to 0. 900 $maxage = 0; 901 if ( isset( $this->mCacheControl['max-age'] ) ) { 902 $maxage = $this->mCacheControl['max-age']; 903 } elseif ( ( $this->mModule && !$this->mModule->isWriteMode() ) || 904 $this->mCacheMode !== 'private' 905 ) { 906 $maxage = $this->getParameter( 'maxage' ); 907 } 908 $privateCache = 'private, must-revalidate, max-age=' . $maxage; 909 910 if ( $this->mCacheMode == 'private' ) { 911 $response->header( "Cache-Control: $privateCache" ); 912 return; 913 } 914 915 if ( $this->mCacheMode == 'anon-public-user-private' ) { 916 $out->addVaryHeader( 'Cookie' ); 917 $response->header( $out->getVaryHeader() ); 918 if ( SessionManager::getGlobalSession()->isPersistent() ) { 919 // Logged in or otherwise has session (e.g. anonymous users who have edited) 920 // Mark request private 921 $response->header( "Cache-Control: $privateCache" ); 922 923 return; 924 } // else anonymous, send public headers below 925 } 926 927 // Send public headers 928 $response->header( $out->getVaryHeader() ); 929 930 // If nobody called setCacheMaxAge(), use the (s)maxage parameters 931 if ( !isset( $this->mCacheControl['s-maxage'] ) ) { 932 $this->mCacheControl['s-maxage'] = $this->getParameter( 'smaxage' ); 933 } 934 if ( !isset( $this->mCacheControl['max-age'] ) ) { 935 $this->mCacheControl['max-age'] = $this->getParameter( 'maxage' ); 936 } 937 938 if ( !$this->mCacheControl['s-maxage'] && !$this->mCacheControl['max-age'] ) { 939 // Public cache not requested 940 // Sending a Vary header in this case is harmless, and protects us 941 // against conditional calls of setCacheMaxAge(). 942 $response->header( "Cache-Control: $privateCache" ); 943 944 return; 945 } 946 947 $this->mCacheControl['public'] = true; 948 949 // Send an Expires header 950 $maxAge = min( $this->mCacheControl['s-maxage'], $this->mCacheControl['max-age'] ); 951 $expiryUnixTime = ( $maxAge == 0 ? 1 : time() + $maxAge ); 952 $response->header( 'Expires: ' . wfTimestamp( TS_RFC2822, $expiryUnixTime ) ); 953 954 // Construct the Cache-Control header 955 $ccHeader = ''; 956 $separator = ''; 957 foreach ( $this->mCacheControl as $name => $value ) { 958 if ( is_bool( $value ) ) { 959 if ( $value ) { 960 $ccHeader .= $separator . $name; 961 $separator = ', '; 962 } 963 } else { 964 $ccHeader .= $separator . "$name=$value"; 965 $separator = ', '; 966 } 967 } 968 969 $response->header( "Cache-Control: $ccHeader" ); 970 } 971 972 /** 973 * Create the printer for error output 974 */ 975 private function createErrorPrinter() { 976 if ( !isset( $this->mPrinter ) ) { 977 $value = $this->getRequest()->getVal( 'format', self::API_DEFAULT_FORMAT ); 978 if ( !$this->mModuleMgr->isDefined( $value, 'format' ) ) { 979 $value = self::API_DEFAULT_FORMAT; 980 } 981 $this->mPrinter = $this->createPrinterByName( $value ); 982 } 983 984 // Printer may not be able to handle errors. This is particularly 985 // likely if the module returns something for getCustomPrinter(). 986 if ( !$this->mPrinter->canPrintErrors() ) { 987 $this->mPrinter = $this->createPrinterByName( self::API_DEFAULT_FORMAT ); 988 } 989 } 990 991 /** 992 * Create an error message for the given throwable. 993 * 994 * If an ApiUsageException, errors/warnings will be extracted from the 995 * embedded StatusValue. 996 * 997 * Any other throwable will be returned with a generic code and wrapper 998 * text around the throwable's (presumably English) message as a single 999 * error (no warnings). 1000 * 1001 * @param Throwable $e 1002 * @param string $type 'error' or 'warning' 1003 * @return ApiMessage[] 1004 * @since 1.27 1005 */ 1006 protected function errorMessagesFromException( Throwable $e, $type = 'error' ) { 1007 $messages = []; 1008 if ( $e instanceof ApiUsageException ) { 1009 foreach ( $e->getStatusValue()->getErrorsByType( $type ) as $error ) { 1010 $messages[] = ApiMessage::create( $error ); 1011 } 1012 } elseif ( $type !== 'error' ) { 1013 // None of the rest have any messages for non-error types 1014 } else { 1015 // Something is seriously wrong 1016 $config = $this->getConfig(); 1017 // TODO: Avoid embedding arbitrary class names in the error code. 1018 $class = preg_replace( '#^Wikimedia\\\Rdbms\\\#', '', get_class( $e ) ); 1019 $code = 'internal_api_error_' . $class; 1020 $data = [ 'errorclass' => get_class( $e ) ]; 1021 if ( $config->get( 'ShowExceptionDetails' ) ) { 1022 if ( $e instanceof ILocalizedException ) { 1023 $msg = $e->getMessageObject(); 1024 } elseif ( $e instanceof MessageSpecifier ) { 1025 $msg = Message::newFromSpecifier( $e ); 1026 } else { 1027 $msg = wfEscapeWikiText( $e->getMessage() ); 1028 } 1029 $params = [ 'apierror-exceptioncaught', WebRequest::getRequestId(), $msg ]; 1030 } else { 1031 $params = [ 'apierror-exceptioncaughttype', WebRequest::getRequestId(), get_class( $e ) ]; 1032 } 1033 1034 $messages[] = ApiMessage::create( $params, $code, $data ); 1035 } 1036 return $messages; 1037 } 1038 1039 /** 1040 * Replace the result data with the information about a throwable. 1041 * @param Throwable $e 1042 * @return string[] Error codes 1043 */ 1044 protected function substituteResultWithError( Throwable $e ) { 1045 $result = $this->getResult(); 1046 $formatter = $this->getErrorFormatter(); 1047 $config = $this->getConfig(); 1048 $errorCodes = []; 1049 1050 // Remember existing warnings and errors across the reset 1051 $errors = $result->getResultData( [ 'errors' ] ); 1052 $warnings = $result->getResultData( [ 'warnings' ] ); 1053 $result->reset(); 1054 if ( $warnings !== null ) { 1055 $result->addValue( null, 'warnings', $warnings, ApiResult::NO_SIZE_CHECK ); 1056 } 1057 if ( $errors !== null ) { 1058 $result->addValue( null, 'errors', $errors, ApiResult::NO_SIZE_CHECK ); 1059 1060 // Collect the copied error codes for the return value 1061 foreach ( $errors as $error ) { 1062 if ( isset( $error['code'] ) ) { 1063 $errorCodes[$error['code']] = true; 1064 } 1065 } 1066 } 1067 1068 // Add errors from the exception 1069 $modulePath = $e instanceof ApiUsageException ? $e->getModulePath() : null; 1070 foreach ( $this->errorMessagesFromException( $e, 'error' ) as $msg ) { 1071 if ( ApiErrorFormatter::isValidApiCode( $msg->getApiCode() ) ) { 1072 $errorCodes[$msg->getApiCode()] = true; 1073 } else { 1074 LoggerFactory::getInstance( 'api-warning' )->error( 'Invalid API error code "{code}"', [ 1075 'code' => $msg->getApiCode(), 1076 'exception' => $e, 1077 ] ); 1078 $errorCodes['<invalid-code>'] = true; 1079 } 1080 $formatter->addError( $modulePath, $msg ); 1081 } 1082 foreach ( $this->errorMessagesFromException( $e, 'warning' ) as $msg ) { 1083 $formatter->addWarning( $modulePath, $msg ); 1084 } 1085 1086 // Add additional data. Path depends on whether we're in BC mode or not. 1087 // Data depends on the type of exception. 1088 if ( $formatter instanceof ApiErrorFormatter_BackCompat ) { 1089 $path = [ 'error' ]; 1090 } else { 1091 $path = null; 1092 } 1093 if ( $e instanceof ApiUsageException ) { 1094 $link = wfExpandUrl( wfScript( 'api' ) ); 1095 $result->addContentValue( 1096 $path, 1097 'docref', 1098 trim( 1099 $this->msg( 'api-usage-docref', $link )->inLanguage( $formatter->getLanguage() )->text() 1100 . ' ' 1101 . $this->msg( 'api-usage-mailinglist-ref' )->inLanguage( $formatter->getLanguage() )->text() 1102 ) 1103 ); 1104 } elseif ( $config->get( 'ShowExceptionDetails' ) ) { 1105 $result->addContentValue( 1106 $path, 1107 'trace', 1108 $this->msg( 'api-exception-trace', 1109 get_class( $e ), 1110 $e->getFile(), 1111 $e->getLine(), 1112 MWExceptionHandler::getRedactedTraceAsString( $e ) 1113 )->inLanguage( $formatter->getLanguage() )->text() 1114 ); 1115 } 1116 1117 // Add the id and such 1118 $this->addRequestedFields( [ 'servedby' ] ); 1119 1120 return array_keys( $errorCodes ); 1121 } 1122 1123 /** 1124 * Add requested fields to the result 1125 * @param string[] $force Which fields to force even if not requested. Accepted values are: 1126 * - servedby 1127 */ 1128 protected function addRequestedFields( $force = [] ) { 1129 $result = $this->getResult(); 1130 1131 $requestid = $this->getParameter( 'requestid' ); 1132 if ( $requestid !== null ) { 1133 $result->addValue( null, 'requestid', $requestid, ApiResult::NO_SIZE_CHECK ); 1134 } 1135 1136 if ( $this->getConfig()->get( 'ShowHostnames' ) && ( 1137 in_array( 'servedby', $force, true ) || $this->getParameter( 'servedby' ) 1138 ) ) { 1139 $result->addValue( null, 'servedby', wfHostname(), ApiResult::NO_SIZE_CHECK ); 1140 } 1141 1142 if ( $this->getParameter( 'curtimestamp' ) ) { 1143 $result->addValue( null, 'curtimestamp', wfTimestamp( TS_ISO_8601 ), ApiResult::NO_SIZE_CHECK ); 1144 } 1145 1146 if ( $this->getParameter( 'responselanginfo' ) ) { 1147 $result->addValue( null, 'uselang', $this->getLanguage()->getCode(), 1148 ApiResult::NO_SIZE_CHECK ); 1149 $result->addValue( null, 'errorlang', $this->getErrorFormatter()->getLanguage()->getCode(), 1150 ApiResult::NO_SIZE_CHECK ); 1151 } 1152 } 1153 1154 /** 1155 * Set up for the execution. 1156 * @return array 1157 */ 1158 protected function setupExecuteAction() { 1159 $this->addRequestedFields(); 1160 1161 $params = $this->extractRequestParams(); 1162 $this->mAction = $params['action']; 1163 1164 return $params; 1165 } 1166 1167 /** 1168 * Set up the module for response 1169 * @return ApiBase The module that will handle this action 1170 * @throws MWException 1171 * @throws ApiUsageException 1172 */ 1173 protected function setupModule() { 1174 // Instantiate the module requested by the user 1175 $module = $this->mModuleMgr->getModule( $this->mAction, 'action' ); 1176 if ( $module === null ) { 1177 // Probably can't happen 1178 // @codeCoverageIgnoreStart 1179 $this->dieWithError( 1180 [ 'apierror-unknownaction', wfEscapeWikiText( $this->mAction ) ], 'unknown_action' 1181 ); 1182 // @codeCoverageIgnoreEnd 1183 } 1184 $moduleParams = $module->extractRequestParams(); 1185 1186 // Check token, if necessary 1187 if ( $module->needsToken() === true ) { 1188 throw new MWException( 1189 "Module '{$module->getModuleName()}' must be updated for the new token handling. " . 1190 'See documentation for ApiBase::needsToken for details.' 1191 ); 1192 } 1193 if ( $module->needsToken() ) { 1194 if ( !$module->mustBePosted() ) { 1195 throw new MWException( 1196 "Module '{$module->getModuleName()}' must require POST to use tokens." 1197 ); 1198 } 1199 1200 if ( !isset( $moduleParams['token'] ) ) { 1201 // Probably can't happen 1202 // @codeCoverageIgnoreStart 1203 $module->dieWithError( [ 'apierror-missingparam', 'token' ] ); 1204 // @codeCoverageIgnoreEnd 1205 } 1206 1207 $module->requirePostedParameters( [ 'token' ] ); 1208 1209 if ( !$module->validateToken( $moduleParams['token'], $moduleParams ) ) { 1210 $module->dieWithError( 'apierror-badtoken' ); 1211 } 1212 } 1213 1214 return $module; 1215 } 1216 1217 /** 1218 * @return array 1219 */ 1220 private function getMaxLag() { 1221 $dbLag = MediaWikiServices::getInstance()->getDBLoadBalancer()->getMaxLag(); 1222 $lagInfo = [ 1223 'host' => $dbLag[0], 1224 'lag' => $dbLag[1], 1225 'type' => 'db' 1226 ]; 1227 1228 $jobQueueLagFactor = $this->getConfig()->get( 'JobQueueIncludeInMaxLagFactor' ); 1229 if ( $jobQueueLagFactor ) { 1230 // Turn total number of jobs into seconds by using the configured value 1231 $totalJobs = array_sum( JobQueueGroup::singleton()->getQueueSizes() ); 1232 $jobQueueLag = $totalJobs / (float)$jobQueueLagFactor; 1233 if ( $jobQueueLag > $lagInfo['lag'] ) { 1234 $lagInfo = [ 1235 'host' => wfHostname(), // XXX: Is there a better value that could be used? 1236 'lag' => $jobQueueLag, 1237 'type' => 'jobqueue', 1238 'jobs' => $totalJobs, 1239 ]; 1240 } 1241 } 1242 1243 $this->getHookRunner()->onApiMaxLagInfo( $lagInfo ); 1244 1245 return $lagInfo; 1246 } 1247 1248 /** 1249 * Check the max lag if necessary 1250 * @param ApiBase $module Api module being used 1251 * @param array $params Array an array containing the request parameters. 1252 * @return bool True on success, false should exit immediately 1253 */ 1254 protected function checkMaxLag( $module, $params ) { 1255 if ( $module->shouldCheckMaxlag() && isset( $params['maxlag'] ) ) { 1256 $maxLag = $params['maxlag']; 1257 $lagInfo = $this->getMaxLag(); 1258 if ( $lagInfo['lag'] > $maxLag ) { 1259 $response = $this->getRequest()->response(); 1260 1261 $response->header( 'Retry-After: ' . max( (int)$maxLag, 5 ) ); 1262 $response->header( 'X-Database-Lag: ' . (int)$lagInfo['lag'] ); 1263 1264 if ( $this->getConfig()->get( 'ShowHostnames' ) ) { 1265 $this->dieWithError( 1266 [ 'apierror-maxlag', $lagInfo['lag'], $lagInfo['host'] ], 1267 'maxlag', 1268 $lagInfo 1269 ); 1270 } 1271 1272 $this->dieWithError( [ 'apierror-maxlag-generic', $lagInfo['lag'] ], 'maxlag', $lagInfo ); 1273 } 1274 } 1275 1276 return true; 1277 } 1278 1279 /** 1280 * Check selected RFC 7232 precondition headers 1281 * 1282 * RFC 7232 envisions a particular model where you send your request to "a 1283 * resource", and for write requests that you can read "the resource" by 1284 * changing the method to GET. When the API receives a GET request, it 1285 * works out even though "the resource" from RFC 7232's perspective might 1286 * be many resources from MediaWiki's perspective. But it totally fails for 1287 * a POST, since what HTTP sees as "the resource" is probably just 1288 * "/api.php" with all the interesting bits in the body. 1289 * 1290 * Therefore, we only support RFC 7232 precondition headers for GET (and 1291 * HEAD). That means we don't need to bother with If-Match and 1292 * If-Unmodified-Since since they only apply to modification requests. 1293 * 1294 * And since we don't support Range, If-Range is ignored too. 1295 * 1296 * @since 1.26 1297 * @param ApiBase $module Api module being used 1298 * @return bool True on success, false should exit immediately 1299 */ 1300 protected function checkConditionalRequestHeaders( $module ) { 1301 if ( $this->mInternalMode ) { 1302 // No headers to check in internal mode 1303 return true; 1304 } 1305 1306 if ( $this->getRequest()->getMethod() !== 'GET' && $this->getRequest()->getMethod() !== 'HEAD' ) { 1307 // Don't check POSTs 1308 return true; 1309 } 1310 1311 $return304 = false; 1312 1313 $ifNoneMatch = array_diff( 1314 $this->getRequest()->getHeader( 'If-None-Match', WebRequest::GETHEADER_LIST ) ?: [], 1315 [ '' ] 1316 ); 1317 if ( $ifNoneMatch ) { 1318 // @phan-suppress-next-line PhanImpossibleTypeComparison 1319 if ( $ifNoneMatch === [ '*' ] ) { 1320 // API responses always "exist" 1321 $etag = '*'; 1322 } else { 1323 $etag = $module->getConditionalRequestData( 'etag' ); 1324 } 1325 } 1326 if ( $ifNoneMatch && $etag !== null ) { 1327 $test = substr( $etag, 0, 2 ) === 'W/' ? substr( $etag, 2 ) : $etag; 1328 $match = array_map( function ( $s ) { 1329 return substr( $s, 0, 2 ) === 'W/' ? substr( $s, 2 ) : $s; 1330 }, $ifNoneMatch ); 1331 $return304 = in_array( $test, $match, true ); 1332 } else { 1333 $value = trim( $this->getRequest()->getHeader( 'If-Modified-Since' ) ); 1334 1335 // Some old browsers sends sizes after the date, like this: 1336 // Wed, 20 Aug 2003 06:51:19 GMT; length=5202 1337 // Ignore that. 1338 $i = strpos( $value, ';' ); 1339 if ( $i !== false ) { 1340 $value = trim( substr( $value, 0, $i ) ); 1341 } 1342 1343 if ( $value !== '' ) { 1344 try { 1345 $ts = new MWTimestamp( $value ); 1346 if ( 1347 // RFC 7231 IMF-fixdate 1348 $ts->getTimestamp( TS_RFC2822 ) === $value || 1349 // RFC 850 1350 $ts->format( 'l, d-M-y H:i:s' ) . ' GMT' === $value || 1351 // asctime (with and without space-padded day) 1352 $ts->format( 'D M j H:i:s Y' ) === $value || 1353 $ts->format( 'D M j H:i:s Y' ) === $value 1354 ) { 1355 $config = $this->getConfig(); 1356 $lastMod = $module->getConditionalRequestData( 'last-modified' ); 1357 if ( $lastMod !== null ) { 1358 // Mix in some MediaWiki modification times 1359 $modifiedTimes = [ 1360 'page' => $lastMod, 1361 'user' => $this->getUser()->getTouched(), 1362 'epoch' => $config->get( 'CacheEpoch' ), 1363 ]; 1364 1365 if ( $config->get( 'UseCdn' ) ) { 1366 // T46570: the core page itself may not change, but resources might 1367 $modifiedTimes['sepoch'] = wfTimestamp( 1368 TS_MW, time() - $config->get( 'CdnMaxAge' ) 1369 ); 1370 } 1371 $this->getHookRunner()->onOutputPageCheckLastModified( $modifiedTimes, $this->getOutput() ); 1372 $lastMod = max( $modifiedTimes ); 1373 $return304 = wfTimestamp( TS_MW, $lastMod ) <= $ts->getTimestamp( TS_MW ); 1374 } 1375 } 1376 } catch ( TimestampException $e ) { 1377 // Invalid timestamp, ignore it 1378 } 1379 } 1380 } 1381 1382 if ( $return304 ) { 1383 $this->getRequest()->response()->statusHeader( 304 ); 1384 1385 // Avoid outputting the compressed representation of a zero-length body 1386 Wikimedia\suppressWarnings(); 1387 ini_set( 'zlib.output_compression', 0 ); 1388 Wikimedia\restoreWarnings(); 1389 wfClearOutputBuffers(); 1390 1391 return false; 1392 } 1393 1394 return true; 1395 } 1396 1397 /** 1398 * Check for sufficient permissions to execute 1399 * @param ApiBase $module An Api module 1400 */ 1401 protected function checkExecutePermissions( $module ) { 1402 $user = $this->getUser(); 1403 if ( $module->isReadMode() && !$this->getPermissionManager()->isEveryoneAllowed( 'read' ) && 1404 !$this->getPermissionManager()->userHasRight( $user, 'read' ) 1405 ) { 1406 $this->dieWithError( 'apierror-readapidenied' ); 1407 } 1408 1409 if ( $module->isWriteMode() ) { 1410 if ( !$this->mEnableWrite ) { 1411 $this->dieWithError( 'apierror-noapiwrite' ); 1412 } elseif ( !$this->getPermissionManager()->userHasRight( $user, 'writeapi' ) ) { 1413 $this->dieWithError( 'apierror-writeapidenied' ); 1414 } elseif ( $this->getRequest()->getHeader( 'Promise-Non-Write-API-Action' ) ) { 1415 $this->dieWithError( 'apierror-promised-nonwrite-api' ); 1416 } 1417 1418 $this->checkReadOnly( $module ); 1419 } 1420 1421 // Allow extensions to stop execution for arbitrary reasons. 1422 $message = 'hookaborted'; 1423 if ( !$this->getHookRunner()->onApiCheckCanExecute( $module, $user, $message ) ) { 1424 $this->dieWithError( $message ); 1425 } 1426 } 1427 1428 /** 1429 * Check if the DB is read-only for this user 1430 * @param ApiBase $module An Api module 1431 */ 1432 protected function checkReadOnly( $module ) { 1433 if ( wfReadOnly() ) { 1434 $this->dieReadOnly(); 1435 } 1436 1437 if ( $module->isWriteMode() 1438 && $this->getUser()->isBot() 1439 && MediaWikiServices::getInstance()->getDBLoadBalancer()->getServerCount() > 1 1440 ) { 1441 $this->checkBotReadOnly(); 1442 } 1443 } 1444 1445 /** 1446 * Check whether we are readonly for bots 1447 */ 1448 private function checkBotReadOnly() { 1449 // Figure out how many servers have passed the lag threshold 1450 $numLagged = 0; 1451 $lagLimit = $this->getConfig()->get( 'APIMaxLagThreshold' ); 1452 $laggedServers = []; 1453 $loadBalancer = MediaWikiServices::getInstance()->getDBLoadBalancer(); 1454 foreach ( $loadBalancer->getLagTimes() as $serverIndex => $lag ) { 1455 if ( $lag > $lagLimit ) { 1456 ++$numLagged; 1457 $laggedServers[] = $loadBalancer->getServerName( $serverIndex ) . " ({$lag}s)"; 1458 } 1459 } 1460 1461 // If a majority of replica DBs are too lagged then disallow writes 1462 $replicaCount = $loadBalancer->getServerCount() - 1; 1463 if ( $numLagged >= ceil( $replicaCount / 2 ) ) { 1464 $laggedServers = implode( ', ', $laggedServers ); 1465 wfDebugLog( 1466 'api-readonly', // Deprecate this channel in favor of api-warning? 1467 "Api request failed as read only because the following DBs are lagged: $laggedServers" 1468 ); 1469 LoggerFactory::getInstance( 'api-warning' )->warning( 1470 "Api request failed as read only because the following DBs are lagged: {laggeddbs}", [ 1471 'laggeddbs' => $laggedServers, 1472 ] 1473 ); 1474 1475 $this->dieWithError( 1476 'readonly_lag', 1477 'readonly', 1478 [ 'readonlyreason' => "Waiting for $numLagged lagged database(s)" ] 1479 ); 1480 } 1481 } 1482 1483 /** 1484 * Check asserts of the user's rights 1485 * @param array $params 1486 */ 1487 protected function checkAsserts( $params ) { 1488 if ( isset( $params['assert'] ) ) { 1489 $user = $this->getUser(); 1490 switch ( $params['assert'] ) { 1491 case 'anon': 1492 if ( !$user->isAnon() ) { 1493 $this->dieWithError( 'apierror-assertanonfailed' ); 1494 } 1495 break; 1496 case 'user': 1497 if ( $user->isAnon() ) { 1498 $this->dieWithError( 'apierror-assertuserfailed' ); 1499 } 1500 break; 1501 case 'bot': 1502 if ( !$this->getPermissionManager()->userHasRight( $user, 'bot' ) ) { 1503 $this->dieWithError( 'apierror-assertbotfailed' ); 1504 } 1505 break; 1506 } 1507 } 1508 if ( isset( $params['assertuser'] ) ) { 1509 $assertUser = User::newFromName( $params['assertuser'], false ); 1510 if ( !$assertUser || !$this->getUser()->equals( $assertUser ) ) { 1511 $this->dieWithError( 1512 [ 'apierror-assertnameduserfailed', wfEscapeWikiText( $params['assertuser'] ) ] 1513 ); 1514 } 1515 } 1516 } 1517 1518 /** 1519 * Check POST for external response and setup result printer 1520 * @param ApiBase $module An Api module 1521 * @param array $params An array with the request parameters 1522 */ 1523 protected function setupExternalResponse( $module, $params ) { 1524 $validMethods = [ 'GET', 'HEAD', 'POST', 'OPTIONS' ]; 1525 $request = $this->getRequest(); 1526 1527 if ( !in_array( $request->getMethod(), $validMethods ) ) { 1528 $this->dieWithError( 'apierror-invalidmethod', null, null, 405 ); 1529 } 1530 1531 if ( !$request->wasPosted() && $module->mustBePosted() ) { 1532 // Module requires POST. GET request might still be allowed 1533 // if $wgDebugApi is true, otherwise fail. 1534 $this->dieWithErrorOrDebug( [ 'apierror-mustbeposted', $this->mAction ] ); 1535 } 1536 1537 if ( $request->wasPosted() && !$request->getHeader( 'Content-Type' ) ) { 1538 $this->addDeprecation( 1539 'apiwarn-deprecation-post-without-content-type', 'post-without-content-type' 1540 ); 1541 } 1542 1543 // See if custom printer is used 1544 $this->mPrinter = $module->getCustomPrinter(); 1545 if ( $this->mPrinter === null ) { 1546 // Create an appropriate printer 1547 $this->mPrinter = $this->createPrinterByName( $params['format'] ); 1548 } 1549 1550 if ( $request->getProtocol() === 'http' && 1551 ( 1552 $this->getConfig()->get( 'ForceHTTPS' ) || 1553 $request->getSession()->shouldForceHTTPS() || 1554 ( $this->getUser()->isLoggedIn() && 1555 $this->getUser()->requiresHTTPS() ) 1556 ) 1557 ) { 1558 $this->addDeprecation( 'apiwarn-deprecation-httpsexpected', 'https-expected' ); 1559 } 1560 } 1561 1562 /** 1563 * Execute the actual module, without any error handling 1564 */ 1565 protected function executeAction() { 1566 $params = $this->setupExecuteAction(); 1567 1568 // Check asserts early so e.g. errors in parsing a module's parameters due to being 1569 // logged out don't override the client's intended "am I logged in?" check. 1570 $this->checkAsserts( $params ); 1571 1572 $module = $this->setupModule(); 1573 $this->mModule = $module; 1574 1575 if ( !$this->mInternalMode ) { 1576 $this->setRequestExpectations( $module ); 1577 } 1578 1579 $this->checkExecutePermissions( $module ); 1580 1581 if ( !$this->checkMaxLag( $module, $params ) ) { 1582 return; 1583 } 1584 1585 if ( !$this->checkConditionalRequestHeaders( $module ) ) { 1586 return; 1587 } 1588 1589 if ( !$this->mInternalMode ) { 1590 $this->setupExternalResponse( $module, $params ); 1591 } 1592 1593 $module->execute(); 1594 $this->getHookRunner()->onAPIAfterExecute( $module ); 1595 1596 $this->reportUnusedParams(); 1597 1598 if ( !$this->mInternalMode ) { 1599 MWDebug::appendDebugInfoToApiResult( $this->getContext(), $this->getResult() ); 1600 1601 $this->printResult(); 1602 } 1603 } 1604 1605 /** 1606 * Set database connection, query, and write expectations given this module request 1607 * @param ApiBase $module 1608 */ 1609 protected function setRequestExpectations( ApiBase $module ) { 1610 $limits = $this->getConfig()->get( 'TrxProfilerLimits' ); 1611 $trxProfiler = Profiler::instance()->getTransactionProfiler(); 1612 $trxProfiler->setLogger( LoggerFactory::getInstance( 'DBPerformance' ) ); 1613 if ( $this->getRequest()->hasSafeMethod() ) { 1614 $trxProfiler->setExpectations( $limits['GET'], __METHOD__ ); 1615 } elseif ( $this->getRequest()->wasPosted() && !$module->isWriteMode() ) { 1616 $trxProfiler->setExpectations( $limits['POST-nonwrite'], __METHOD__ ); 1617 $this->getRequest()->markAsSafeRequest(); 1618 } else { 1619 $trxProfiler->setExpectations( $limits['POST'], __METHOD__ ); 1620 } 1621 } 1622 1623 /** 1624 * Log the preceding request 1625 * @param float $time Time in seconds 1626 * @param Throwable|null $e Throwable caught while processing the request 1627 */ 1628 protected function logRequest( $time, Throwable $e = null ) { 1629 $request = $this->getRequest(); 1630 1631 $logCtx = [ 1632 // https://gerrit.wikimedia.org/r/plugins/gitiles/mediawiki/event-schemas/+/master/jsonschema/mediawiki/api/request 1633 '$schema' => '/mediawiki/api/request/0.0.1', 1634 'meta' => [ 1635 'request_id' => WebRequest::getRequestId(), 1636 'id' => MediaWikiServices::getInstance() 1637 ->getGlobalIdGenerator()->newUUIDv4(), 1638 'dt' => wfTimestamp( TS_ISO_8601 ), 1639 'domain' => $this->getConfig()->get( 'ServerName' ), 1640 // If using the EventBus extension (as intended) with this log channel, 1641 // this stream name will map to a Kafka topic. 1642 'stream' => 'mediawiki.api-request' 1643 ], 1644 'http' => [ 1645 'method' => $request->getMethod(), 1646 'client_ip' => $request->getIP() 1647 ], 1648 'database' => WikiMap::getCurrentWikiDbDomain()->getId(), 1649 'backend_time_ms' => (int)round( $time * 1000 ), 1650 ]; 1651 1652 // If set, these headers will be logged in http.request_headers. 1653 $httpRequestHeadersToLog = [ 'accept-language', 'referer', 'user-agent' ]; 1654 foreach ( $httpRequestHeadersToLog as $header ) { 1655 if ( $request->getHeader( $header ) ) { 1656 // Set the header in http.request_headers 1657 $logCtx['http']['request_headers'][$header] = $request->getHeader( $header ); 1658 } 1659 } 1660 1661 if ( $e ) { 1662 $logCtx['api_error_codes'] = []; 1663 foreach ( $this->errorMessagesFromException( $e ) as $msg ) { 1664 $logCtx['api_error_codes'][] = $msg->getApiCode(); 1665 } 1666 } 1667 1668 // Construct space separated message for 'api' log channel 1669 $msg = "API {$request->getMethod()} " . 1670 wfUrlencode( str_replace( ' ', '_', $this->getUser()->getName() ) ) . 1671 " {$logCtx['http']['client_ip']} " . 1672 "T={$logCtx['backend_time_ms']}ms"; 1673 1674 $sensitive = array_flip( $this->getSensitiveParams() ); 1675 foreach ( $this->getParamsUsed() as $name ) { 1676 $value = $request->getVal( $name ); 1677 if ( $value === null ) { 1678 continue; 1679 } 1680 1681 if ( isset( $sensitive[$name] ) ) { 1682 $value = '[redacted]'; 1683 $encValue = '[redacted]'; 1684 } elseif ( strlen( $value ) > 256 ) { 1685 $value = substr( $value, 0, 256 ); 1686 $encValue = $this->encodeRequestLogValue( $value ) . '[...]'; 1687 } else { 1688 $encValue = $this->encodeRequestLogValue( $value ); 1689 } 1690 1691 $logCtx['params'][$name] = $value; 1692 $msg .= " {$name}={$encValue}"; 1693 } 1694 1695 // Log an unstructured message to the api channel. 1696 wfDebugLog( 'api', $msg, 'private' ); 1697 1698 // The api-request channel a structured data log channel. 1699 wfDebugLog( 'api-request', '', 'private', $logCtx ); 1700 } 1701 1702 /** 1703 * Encode a value in a format suitable for a space-separated log line. 1704 * @param string $s 1705 * @return string 1706 */ 1707 protected function encodeRequestLogValue( $s ) { 1708 static $table = []; 1709 if ( !$table ) { 1710 $chars = ';@$!*(),/:'; 1711 $numChars = strlen( $chars ); 1712 for ( $i = 0; $i < $numChars; $i++ ) { 1713 $table[rawurlencode( $chars[$i] )] = $chars[$i]; 1714 } 1715 } 1716 1717 return strtr( rawurlencode( $s ), $table ); 1718 } 1719 1720 /** 1721 * Get the request parameters used in the course of the preceding execute() request 1722 * @return array 1723 */ 1724 protected function getParamsUsed() { 1725 return array_keys( $this->mParamsUsed ); 1726 } 1727 1728 /** 1729 * Mark parameters as used 1730 * @param string|string[] $params 1731 */ 1732 public function markParamsUsed( $params ) { 1733 $this->mParamsUsed += array_fill_keys( (array)$params, true ); 1734 } 1735 1736 /** 1737 * Get the request parameters that should be considered sensitive 1738 * @since 1.29 1739 * @return array 1740 */ 1741 protected function getSensitiveParams() { 1742 return array_keys( $this->mParamsSensitive ); 1743 } 1744 1745 /** 1746 * Mark parameters as sensitive 1747 * @since 1.29 1748 * @param string|string[] $params 1749 */ 1750 public function markParamsSensitive( $params ) { 1751 $this->mParamsSensitive += array_fill_keys( (array)$params, true ); 1752 } 1753 1754 /** 1755 * Get a request value, and register the fact that it was used, for logging. 1756 * @param string $name 1757 * @param string|null $default 1758 * @return string|null 1759 */ 1760 public function getVal( $name, $default = null ) { 1761 $this->mParamsUsed[$name] = true; 1762 1763 $ret = $this->getRequest()->getVal( $name ); 1764 if ( $ret === null ) { 1765 if ( $this->getRequest()->getArray( $name ) !== null ) { 1766 // See T12262 for why we don't just implode( '|', ... ) the 1767 // array. 1768 $this->addWarning( [ 'apiwarn-unsupportedarray', $name ] ); 1769 } 1770 $ret = $default; 1771 } 1772 return $ret; 1773 } 1774 1775 /** 1776 * Get a boolean request value, and register the fact that the parameter 1777 * was used, for logging. 1778 * @param string $name 1779 * @return bool 1780 */ 1781 public function getCheck( $name ) { 1782 $this->mParamsUsed[$name] = true; 1783 return $this->getRequest()->getCheck( $name ); 1784 } 1785 1786 /** 1787 * Get a request upload, and register the fact that it was used, for logging. 1788 * 1789 * @since 1.21 1790 * @param string $name Parameter name 1791 * @return WebRequestUpload 1792 */ 1793 public function getUpload( $name ) { 1794 $this->mParamsUsed[$name] = true; 1795 1796 return $this->getRequest()->getUpload( $name ); 1797 } 1798 1799 /** 1800 * Report unused parameters, so the client gets a hint in case it gave us parameters we don't know, 1801 * for example in case of spelling mistakes or a missing 'g' prefix for generators. 1802 */ 1803 protected function reportUnusedParams() { 1804 $paramsUsed = $this->getParamsUsed(); 1805 $allParams = $this->getRequest()->getValueNames(); 1806 1807 if ( !$this->mInternalMode ) { 1808 // Printer has not yet executed; don't warn that its parameters are unused 1809 $printerParams = $this->mPrinter->encodeParamName( 1810 array_keys( $this->mPrinter->getFinalParams() ?: [] ) 1811 ); 1812 $unusedParams = array_diff( $allParams, $paramsUsed, $printerParams ); 1813 } else { 1814 $unusedParams = array_diff( $allParams, $paramsUsed ); 1815 } 1816 1817 if ( count( $unusedParams ) ) { 1818 $this->addWarning( [ 1819 'apierror-unrecognizedparams', 1820 Message::listParam( array_map( 'wfEscapeWikiText', $unusedParams ), 'comma' ), 1821 count( $unusedParams ) 1822 ] ); 1823 } 1824 } 1825 1826 /** 1827 * Print results using the current printer 1828 * 1829 * @param int $httpCode HTTP status code, or 0 to not change 1830 */ 1831 protected function printResult( $httpCode = 0 ) { 1832 if ( $this->getConfig()->get( 'DebugAPI' ) !== false ) { 1833 $this->addWarning( 'apiwarn-wgdebugapi' ); 1834 } 1835 1836 $printer = $this->mPrinter; 1837 $printer->initPrinter( false ); 1838 if ( $httpCode ) { 1839 $printer->setHttpStatus( $httpCode ); 1840 } 1841 $printer->execute(); 1842 $printer->closePrinter(); 1843 } 1844 1845 /** 1846 * @return bool 1847 */ 1848 public function isReadMode() { 1849 return false; 1850 } 1851 1852 /** 1853 * See ApiBase for description. 1854 * 1855 * @return array 1856 */ 1857 public function getAllowedParams() { 1858 return [ 1859 'action' => [ 1860 ApiBase::PARAM_DFLT => 'help', 1861 ApiBase::PARAM_TYPE => 'submodule', 1862 ], 1863 'format' => [ 1864 ApiBase::PARAM_DFLT => self::API_DEFAULT_FORMAT, 1865 ApiBase::PARAM_TYPE => 'submodule', 1866 ], 1867 'maxlag' => [ 1868 ApiBase::PARAM_TYPE => 'integer' 1869 ], 1870 'smaxage' => [ 1871 ApiBase::PARAM_TYPE => 'integer', 1872 ApiBase::PARAM_DFLT => 0 1873 ], 1874 'maxage' => [ 1875 ApiBase::PARAM_TYPE => 'integer', 1876 ApiBase::PARAM_DFLT => 0 1877 ], 1878 'assert' => [ 1879 ApiBase::PARAM_TYPE => [ 'anon', 'user', 'bot' ] 1880 ], 1881 'assertuser' => [ 1882 ApiBase::PARAM_TYPE => 'user', 1883 UserDef::PARAM_ALLOWED_USER_TYPES => [ 'name' ], 1884 ], 1885 'requestid' => null, 1886 'servedby' => false, 1887 'curtimestamp' => false, 1888 'responselanginfo' => false, 1889 'origin' => null, 1890 'uselang' => [ 1891 ApiBase::PARAM_DFLT => self::API_DEFAULT_USELANG, 1892 ], 1893 'errorformat' => [ 1894 ApiBase::PARAM_TYPE => [ 'plaintext', 'wikitext', 'html', 'raw', 'none', 'bc' ], 1895 ApiBase::PARAM_DFLT => 'bc', 1896 ], 1897 'errorlang' => [ 1898 ApiBase::PARAM_DFLT => 'uselang', 1899 ], 1900 'errorsuselocal' => [ 1901 ApiBase::PARAM_DFLT => false, 1902 ], 1903 ]; 1904 } 1905 1906 /** @inheritDoc */ 1907 protected function getExamplesMessages() { 1908 return [ 1909 'action=help' 1910 => 'apihelp-help-example-main', 1911 'action=help&recursivesubmodules=1' 1912 => 'apihelp-help-example-recursive', 1913 ]; 1914 } 1915 1916 /** 1917 * @inheritDoc 1918 * @phan-param array{nolead?:bool,headerlevel?:int,tocnumber?:int[]} $options 1919 */ 1920 public function modifyHelp( array &$help, array $options, array &$tocData ) { 1921 // Wish PHP had an "array_insert_before". Instead, we have to manually 1922 // reindex the array to get 'permissions' in the right place. 1923 $oldHelp = $help; 1924 $help = []; 1925 foreach ( $oldHelp as $k => $v ) { 1926 if ( $k === 'submodules' ) { 1927 $help['permissions'] = ''; 1928 } 1929 $help[$k] = $v; 1930 } 1931 $help['datatypes'] = ''; 1932 $help['templatedparams'] = ''; 1933 $help['credits'] = ''; 1934 1935 // Fill 'permissions' 1936 $help['permissions'] .= Html::openElement( 'div', 1937 [ 'class' => 'apihelp-block apihelp-permissions' ] ); 1938 $m = $this->msg( 'api-help-permissions' ); 1939 if ( !$m->isDisabled() ) { 1940 $help['permissions'] .= Html::rawElement( 'div', [ 'class' => 'apihelp-block-head' ], 1941 $m->numParams( count( self::$mRights ) )->parse() 1942 ); 1943 } 1944 $help['permissions'] .= Html::openElement( 'dl' ); 1945 foreach ( self::$mRights as $right => $rightMsg ) { 1946 $help['permissions'] .= Html::element( 'dt', null, $right ); 1947 1948 $rightMsg = $this->msg( $rightMsg['msg'], $rightMsg['params'] )->parse(); 1949 $help['permissions'] .= Html::rawElement( 'dd', null, $rightMsg ); 1950 1951 $groups = array_map( function ( $group ) { 1952 return $group == '*' ? 'all' : $group; 1953 }, $this->getPermissionManager()->getGroupsWithPermission( $right ) ); 1954 1955 $help['permissions'] .= Html::rawElement( 'dd', null, 1956 $this->msg( 'api-help-permissions-granted-to' ) 1957 ->numParams( count( $groups ) ) 1958 ->params( Message::listParam( $groups ) ) 1959 ->parse() 1960 ); 1961 } 1962 $help['permissions'] .= Html::closeElement( 'dl' ); 1963 $help['permissions'] .= Html::closeElement( 'div' ); 1964 1965 // Fill 'datatypes', 'templatedparams', and 'credits', if applicable 1966 if ( empty( $options['nolead'] ) ) { 1967 $level = $options['headerlevel']; 1968 $tocnumber = &$options['tocnumber']; 1969 1970 $header = $this->msg( 'api-help-datatypes-header' )->parse(); 1971 1972 $id = Sanitizer::escapeIdForAttribute( 'main/datatypes', Sanitizer::ID_PRIMARY ); 1973 $idFallback = Sanitizer::escapeIdForAttribute( 'main/datatypes', Sanitizer::ID_FALLBACK ); 1974 $headline = Linker::makeHeadline( min( 6, $level ), 1975 ' class="apihelp-header">', 1976 $id, 1977 $header, 1978 '', 1979 $idFallback 1980 ); 1981 // Ensure we have a sane anchor 1982 if ( $id !== 'main/datatypes' && $idFallback !== 'main/datatypes' ) { 1983 $headline = '<div id="main/datatypes"></div>' . $headline; 1984 } 1985 $help['datatypes'] .= $headline; 1986 $help['datatypes'] .= $this->msg( 'api-help-datatypes-top' )->parseAsBlock(); 1987 $help['datatypes'] .= '<dl>'; 1988 foreach ( $this->getParamValidator()->knownTypes() as $type ) { 1989 $m = $this->msg( "api-help-datatype-$type" ); 1990 if ( !$m->isDisabled() ) { 1991 $id = "main/datatype/$type"; 1992 $help['datatypes'] .= '<dt id="' . htmlspecialchars( $id ) . '">'; 1993 $encId = Sanitizer::escapeIdForAttribute( $id, Sanitizer::ID_PRIMARY ); 1994 if ( $encId !== $id ) { 1995 $help['datatypes'] .= '<span id="' . htmlspecialchars( $encId ) . '"></span>'; 1996 } 1997 $encId2 = Sanitizer::escapeIdForAttribute( $id, Sanitizer::ID_FALLBACK ); 1998 if ( $encId2 !== $id && $encId2 !== $encId ) { 1999 $help['datatypes'] .= '<span id="' . htmlspecialchars( $encId2 ) . '"></span>'; 2000 } 2001 $help['datatypes'] .= htmlspecialchars( $type ) . '</dt><dd>' . $m->parseAsBlock() . "</dd>"; 2002 } 2003 } 2004 $help['datatypes'] .= '</dl>'; 2005 if ( !isset( $tocData['main/datatypes'] ) ) { 2006 $tocnumber[$level]++; 2007 $tocData['main/datatypes'] = [ 2008 'toclevel' => count( $tocnumber ), 2009 'level' => $level, 2010 'anchor' => 'main/datatypes', 2011 'line' => $header, 2012 'number' => implode( '.', $tocnumber ), 2013 'index' => false, 2014 ]; 2015 } 2016 2017 $header = $this->msg( 'api-help-templatedparams-header' )->parse(); 2018 2019 $id = Sanitizer::escapeIdForAttribute( 'main/templatedparams', Sanitizer::ID_PRIMARY ); 2020 $idFallback = Sanitizer::escapeIdForAttribute( 'main/templatedparams', Sanitizer::ID_FALLBACK ); 2021 $headline = Linker::makeHeadline( min( 6, $level ), 2022 ' class="apihelp-header">', 2023 $id, 2024 $header, 2025 '', 2026 $idFallback 2027 ); 2028 // Ensure we have a sane anchor 2029 if ( $id !== 'main/templatedparams' && $idFallback !== 'main/templatedparams' ) { 2030 $headline = '<div id="main/templatedparams"></div>' . $headline; 2031 } 2032 $help['templatedparams'] .= $headline; 2033 $help['templatedparams'] .= $this->msg( 'api-help-templatedparams' )->parseAsBlock(); 2034 if ( !isset( $tocData['main/templatedparams'] ) ) { 2035 $tocnumber[$level]++; 2036 $tocData['main/templatedparams'] = [ 2037 'toclevel' => count( $tocnumber ), 2038 'level' => $level, 2039 'anchor' => 'main/templatedparams', 2040 'line' => $header, 2041 'number' => implode( '.', $tocnumber ), 2042 'index' => false, 2043 ]; 2044 } 2045 2046 $header = $this->msg( 'api-credits-header' )->parse(); 2047 $id = Sanitizer::escapeIdForAttribute( 'main/credits', Sanitizer::ID_PRIMARY ); 2048 $idFallback = Sanitizer::escapeIdForAttribute( 'main/credits', Sanitizer::ID_FALLBACK ); 2049 $headline = Linker::makeHeadline( min( 6, $level ), 2050 ' class="apihelp-header">', 2051 $id, 2052 $header, 2053 '', 2054 $idFallback 2055 ); 2056 // Ensure we have a sane anchor 2057 if ( $id !== 'main/credits' && $idFallback !== 'main/credits' ) { 2058 $headline = '<div id="main/credits"></div>' . $headline; 2059 } 2060 $help['credits'] .= $headline; 2061 $help['credits'] .= $this->msg( 'api-credits' )->useDatabase( false )->parseAsBlock(); 2062 if ( !isset( $tocData['main/credits'] ) ) { 2063 $tocnumber[$level]++; 2064 $tocData['main/credits'] = [ 2065 'toclevel' => count( $tocnumber ), 2066 'level' => $level, 2067 'anchor' => 'main/credits', 2068 'line' => $header, 2069 'number' => implode( '.', $tocnumber ), 2070 'index' => false, 2071 ]; 2072 } 2073 } 2074 } 2075 2076 private $mCanApiHighLimits = null; 2077 2078 /** 2079 * Check whether the current user is allowed to use high limits 2080 * @return bool 2081 */ 2082 public function canApiHighLimits() { 2083 if ( !isset( $this->mCanApiHighLimits ) ) { 2084 $this->mCanApiHighLimits = $this->getPermissionManager() 2085 ->userHasRight( $this->getUser(), 'apihighlimits' ); 2086 } 2087 2088 return $this->mCanApiHighLimits; 2089 } 2090 2091 /** 2092 * Overrides to return this instance's module manager. 2093 * @return ApiModuleManager 2094 */ 2095 public function getModuleManager() { 2096 return $this->mModuleMgr; 2097 } 2098 2099 /** 2100 * Fetches the user agent used for this request 2101 * 2102 * The value will be the combination of the 'Api-User-Agent' header (if 2103 * any) and the standard User-Agent header (if any). 2104 * 2105 * @return string 2106 */ 2107 public function getUserAgent() { 2108 return trim( 2109 $this->getRequest()->getHeader( 'Api-user-agent' ) . ' ' . 2110 $this->getRequest()->getHeader( 'User-agent' ) 2111 ); 2112 } 2113} 2114 2115/** 2116 * For really cool vim folding this needs to be at the end: 2117 * vim: foldmarker=@{,@} foldmethod=marker 2118 */ 2119