Completed
Branch master (939199)
by
unknown
39:35
created

includes/api/ApiMain.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

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

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
1772
					$header;
1773
			}
1774
			$header = $this->msg( 'api-credits-header' )->parse();
1775
			$help['credits'] .= Html::rawElement( 'h' . min( 6, $level ),
1776
				[ 'id' => 'main/credits', 'class' => 'apihelp-header' ],
1777
				$header
1778
			);
1779
			$help['credits'] .= $this->msg( 'api-credits' )->useDatabase( false )->parseAsBlock();
1780 View Code Duplication
			if ( !isset( $tocData['main/credits'] ) ) {
1781
				$tocnumber[$level]++;
1782
				$tocData['main/credits'] = [
1783
					'toclevel' => count( $tocnumber ),
1784
					'level' => $level,
1785
					'anchor' => 'main/credits',
1786
					'line' => $header,
1787
					'number' => implode( '.', $tocnumber ),
1788
					'index' => false,
1789
				];
1790
			}
1791
		}
1792
	}
1793
1794
	private $mCanApiHighLimits = null;
1795
1796
	/**
1797
	 * Check whether the current user is allowed to use high limits
1798
	 * @return bool
1799
	 */
1800
	public function canApiHighLimits() {
1801
		if ( !isset( $this->mCanApiHighLimits ) ) {
1802
			$this->mCanApiHighLimits = $this->getUser()->isAllowed( 'apihighlimits' );
1803
		}
1804
1805
		return $this->mCanApiHighLimits;
1806
	}
1807
1808
	/**
1809
	 * Overrides to return this instance's module manager.
1810
	 * @return ApiModuleManager
1811
	 */
1812
	public function getModuleManager() {
1813
		return $this->mModuleMgr;
1814
	}
1815
1816
	/**
1817
	 * Fetches the user agent used for this request
1818
	 *
1819
	 * The value will be the combination of the 'Api-User-Agent' header (if
1820
	 * any) and the standard User-Agent header (if any).
1821
	 *
1822
	 * @return string
1823
	 */
1824
	public function getUserAgent() {
1825
		return trim(
1826
			$this->getRequest()->getHeader( 'Api-user-agent' ) . ' ' .
1827
			$this->getRequest()->getHeader( 'User-agent' )
1828
		);
1829
	}
1830
}
1831
1832
/**
1833
 * This exception will be thrown when dieUsage is called to stop module execution.
1834
 *
1835
 * @ingroup API
1836
 */
1837
class UsageException extends MWException {
1838
1839
	private $mCodestr;
1840
1841
	/**
1842
	 * @var null|array
1843
	 */
1844
	private $mExtraData;
1845
1846
	/**
1847
	 * @param string $message
1848
	 * @param string $codestr
1849
	 * @param int $code
1850
	 * @param array|null $extradata
1851
	 */
1852
	public function __construct( $message, $codestr, $code = 0, $extradata = null ) {
1853
		parent::__construct( $message, $code );
1854
		$this->mCodestr = $codestr;
1855
		$this->mExtraData = $extradata;
1856
1857
		// This should never happen, so throw an exception about it that will
1858
		// hopefully get logged with a backtrace (T138585)
1859
		if ( !is_string( $codestr ) || $codestr === '' ) {
1860
			throw new InvalidArgumentException( 'Invalid $codestr, was ' .
1861
				( $codestr === '' ? 'empty string' : gettype( $codestr ) )
1862
			);
1863
		}
1864
	}
1865
1866
	/**
1867
	 * @return string
1868
	 */
1869
	public function getCodeString() {
1870
		return $this->mCodestr;
1871
	}
1872
1873
	/**
1874
	 * @return array
1875
	 */
1876
	public function getMessageArray() {
1877
		$result = [
1878
			'code' => $this->mCodestr,
1879
			'info' => $this->getMessage()
1880
		];
1881
		if ( is_array( $this->mExtraData ) ) {
1882
			$result = array_merge( $result, $this->mExtraData );
1883
		}
1884
1885
		return $result;
1886
	}
1887
1888
	/**
1889
	 * @return string
1890
	 */
1891
	public function __toString() {
1892
		return "{$this->getCodeString()}: {$this->getMessage()}";
1893
	}
1894
}
1895
1896
/**
1897
 * For really cool vim folding this needs to be at the end:
1898
 * vim: foldmarker=@{,@} foldmethod=marker
1899
 */
1900