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;
0 ignored issues
show
The property mCommit does not exist. Did you maybe forget to declare it?

In PHP it is possible to write to properties without declaring them. For example, the following is perfectly valid PHP code:

class MyClass { }

$x = new MyClass();
$x->foo = true;

Generally, it is a good practice to explictly declare properties to avoid accidental typos and provide IDE auto-completion:

class MyClass {
    public $foo;
}

$x = new MyClass();
$x->foo = true;
Loading history...
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' ) ] ) .
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