ApiMain.php 65 KB

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