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

includes/MediaWiki.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
 * Helper class for the index.php entry point.
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 */
22
23
use MediaWiki\Logger\LoggerFactory;
24
use MediaWiki\MediaWikiServices;
25
26
/**
27
 * The MediaWiki class is the helper class for the index.php entry point.
28
 */
29
class MediaWiki {
30
	/**
31
	 * @var IContextSource
32
	 */
33
	private $context;
34
35
	/**
36
	 * @var Config
37
	 */
38
	private $config;
39
40
	/**
41
	 * @var String Cache what action this request is
42
	 */
43
	private $action;
44
45
	/**
46
	 * @param IContextSource|null $context
47
	 */
48
	public function __construct( IContextSource $context = null ) {
49
		if ( !$context ) {
50
			$context = RequestContext::getMain();
51
		}
52
53
		$this->context = $context;
54
		$this->config = $context->getConfig();
55
	}
56
57
	/**
58
	 * Parse the request to get the Title object
59
	 *
60
	 * @throws MalformedTitleException If a title has been provided by the user, but is invalid.
61
	 * @return Title Title object to be $wgTitle
62
	 */
63
	private function parseTitle() {
64
		global $wgContLang;
65
66
		$request = $this->context->getRequest();
67
		$curid = $request->getInt( 'curid' );
68
		$title = $request->getVal( 'title' );
69
		$action = $request->getVal( 'action' );
70
71
		if ( $request->getCheck( 'search' ) ) {
72
			// Compatibility with old search URLs which didn't use Special:Search
73
			// Just check for presence here, so blank requests still
74
			// show the search page when using ugly URLs (bug 8054).
75
			$ret = SpecialPage::getTitleFor( 'Search' );
76
		} elseif ( $curid ) {
77
			// URLs like this are generated by RC, because rc_title isn't always accurate
78
			$ret = Title::newFromID( $curid );
79
		} else {
80
			$ret = Title::newFromURL( $title );
81
			// Alias NS_MEDIA page URLs to NS_FILE...we only use NS_MEDIA
82
			// in wikitext links to tell Parser to make a direct file link
83
			if ( !is_null( $ret ) && $ret->getNamespace() == NS_MEDIA ) {
84
				$ret = Title::makeTitle( NS_FILE, $ret->getDBkey() );
85
			}
86
			// Check variant links so that interwiki links don't have to worry
87
			// about the possible different language variants
88
			if ( count( $wgContLang->getVariants() ) > 1
89
				&& !is_null( $ret ) && $ret->getArticleID() == 0
90
			) {
91
				$wgContLang->findVariantLink( $title, $ret );
92
			}
93
		}
94
95
		// If title is not provided, always allow oldid and diff to set the title.
96
		// If title is provided, allow oldid and diff to override the title, unless
97
		// we are talking about a special page which might use these parameters for
98
		// other purposes.
99
		if ( $ret === null || !$ret->isSpecialPage() ) {
100
			// We can have urls with just ?diff=,?oldid= or even just ?diff=
101
			$oldid = $request->getInt( 'oldid' );
102
			$oldid = $oldid ? $oldid : $request->getInt( 'diff' );
103
			// Allow oldid to override a changed or missing title
104
			if ( $oldid ) {
105
				$rev = Revision::newFromId( $oldid );
106
				$ret = $rev ? $rev->getTitle() : $ret;
107
			}
108
		}
109
110
		// Use the main page as default title if nothing else has been provided
111
		if ( $ret === null
112
			&& strval( $title ) === ''
113
			&& !$request->getCheck( 'curid' )
114
			&& $action !== 'delete'
115
		) {
116
			$ret = Title::newMainPage();
117
		}
118
119
		if ( $ret === null || ( $ret->getDBkey() == '' && !$ret->isExternal() ) ) {
120
			// If we get here, we definitely don't have a valid title; throw an exception.
121
			// Try to get detailed invalid title exception first, fall back to MalformedTitleException.
122
			Title::newFromTextThrow( $title );
123
			throw new MalformedTitleException( 'badtitletext', $title );
124
		}
125
126
		return $ret;
127
	}
128
129
	/**
130
	 * Get the Title object that we'll be acting on, as specified in the WebRequest
131
	 * @return Title
132
	 */
133
	public function getTitle() {
134
		if ( !$this->context->hasTitle() ) {
135
			try {
136
				$this->context->setTitle( $this->parseTitle() );
137
			} catch ( MalformedTitleException $ex ) {
138
				$this->context->setTitle( SpecialPage::getTitleFor( 'Badtitle' ) );
139
			}
140
		}
141
		return $this->context->getTitle();
142
	}
143
144
	/**
145
	 * Returns the name of the action that will be executed.
146
	 *
147
	 * @return string Action
148
	 */
149
	public function getAction() {
150
		if ( $this->action === null ) {
151
			$this->action = Action::getActionName( $this->context );
152
		}
153
154
		return $this->action;
155
	}
156
157
	/**
158
	 * Performs the request.
159
	 * - bad titles
160
	 * - read restriction
161
	 * - local interwiki redirects
162
	 * - redirect loop
163
	 * - special pages
164
	 * - normal pages
165
	 *
166
	 * @throws MWException|PermissionsError|BadTitleError|HttpError
167
	 * @return void
168
	 */
169
	private function performRequest() {
170
		global $wgTitle;
171
172
		$request = $this->context->getRequest();
173
		$requestTitle = $title = $this->context->getTitle();
174
		$output = $this->context->getOutput();
175
		$user = $this->context->getUser();
176
177
		if ( $request->getVal( 'printable' ) === 'yes' ) {
178
			$output->setPrintable();
179
		}
180
181
		$unused = null; // To pass it by reference
182
		Hooks::run( 'BeforeInitialize', [ &$title, &$unused, &$output, &$user, $request, $this ] );
183
184
		// Invalid titles. Bug 21776: The interwikis must redirect even if the page name is empty.
185
		if ( is_null( $title ) || ( $title->getDBkey() == '' && !$title->isExternal() )
186
			|| $title->isSpecial( 'Badtitle' )
187
		) {
188
			$this->context->setTitle( SpecialPage::getTitleFor( 'Badtitle' ) );
189
			try {
190
				$this->parseTitle();
191
			} catch ( MalformedTitleException $ex ) {
192
				throw new BadTitleError( $ex );
193
			}
194
			throw new BadTitleError();
195
		}
196
197
		// Check user's permissions to read this page.
198
		// We have to check here to catch special pages etc.
199
		// We will check again in Article::view().
200
		$permErrors = $title->isSpecial( 'RunJobs' )
201
			? [] // relies on HMAC key signature alone
202
			: $title->getUserPermissionsErrors( 'read', $user );
203
		if ( count( $permErrors ) ) {
204
			// Bug 32276: allowing the skin to generate output with $wgTitle or
205
			// $this->context->title set to the input title would allow anonymous users to
206
			// determine whether a page exists, potentially leaking private data. In fact, the
207
			// curid and oldid request  parameters would allow page titles to be enumerated even
208
			// when they are not guessable. So we reset the title to Special:Badtitle before the
209
			// permissions error is displayed.
210
211
			// The skin mostly uses $this->context->getTitle() these days, but some extensions
212
			// still use $wgTitle.
213
			$badTitle = SpecialPage::getTitleFor( 'Badtitle' );
214
			$this->context->setTitle( $badTitle );
215
			$wgTitle = $badTitle;
216
217
			throw new PermissionsError( 'read', $permErrors );
218
		}
219
220
		// Interwiki redirects
221
		if ( $title->isExternal() ) {
222
			$rdfrom = $request->getVal( 'rdfrom' );
223
			if ( $rdfrom ) {
224
				$url = $title->getFullURL( [ 'rdfrom' => $rdfrom ] );
225
			} else {
226
				$query = $request->getValues();
227
				unset( $query['title'] );
228
				$url = $title->getFullURL( $query );
229
			}
230
			// Check for a redirect loop
231
			if ( !preg_match( '/^' . preg_quote( $this->config->get( 'Server' ), '/' ) . '/', $url )
232
				&& $title->isLocal()
233
			) {
234
				// 301 so google et al report the target as the actual url.
235
				$output->redirect( $url, 301 );
236
			} else {
237
				$this->context->setTitle( SpecialPage::getTitleFor( 'Badtitle' ) );
238
				try {
239
					$this->parseTitle();
240
				} catch ( MalformedTitleException $ex ) {
241
					throw new BadTitleError( $ex );
242
				}
243
				throw new BadTitleError();
244
			}
245
		// Handle any other redirects.
246
		// Redirect loops, titleless URL, $wgUsePathInfo URLs, and URLs with a variant
247
		} elseif ( !$this->tryNormaliseRedirect( $title ) ) {
248
			// Prevent information leak via Special:MyPage et al (T109724)
249
			if ( $title->isSpecialPage() ) {
250
				$specialPage = SpecialPageFactory::getPage( $title->getDBkey() );
251
				if ( $specialPage instanceof RedirectSpecialPage ) {
252
					$specialPage->setContext( $this->context );
253
					if ( $this->config->get( 'HideIdentifiableRedirects' )
254
						&& $specialPage->personallyIdentifiableTarget()
255
					) {
256
						list( , $subpage ) = SpecialPageFactory::resolveAlias( $title->getDBkey() );
257
						$target = $specialPage->getRedirect( $subpage );
258
						// target can also be true. We let that case fall through to normal processing.
259
						if ( $target instanceof Title ) {
260
							$query = $specialPage->getRedirectQuery() ?: [];
261
							$request = new DerivativeRequest( $this->context->getRequest(), $query );
262
							$request->setRequestURL( $this->context->getRequest()->getRequestURL() );
263
							$this->context->setRequest( $request );
264
							// Do not varnish cache these. May vary even for anons
265
							$this->context->getOutput()->lowerCdnMaxage( 0 );
266
							$this->context->setTitle( $target );
267
							$wgTitle = $target;
268
							// Reset action type cache. (Special pages have only view)
269
							$this->action = null;
270
							$title = $target;
271
							$output->addJsConfigVars( [
272
								'wgInternalRedirectTargetUrl' => $target->getFullURL( $query ),
273
							] );
274
							$output->addModules( 'mediawiki.action.view.redirect' );
275
						}
276
					}
277
				}
278
			}
279
280
			// Special pages ($title may have changed since if statement above)
281
			if ( NS_SPECIAL == $title->getNamespace() ) {
282
				// Actions that need to be made when we have a special pages
283
				SpecialPageFactory::executePath( $title, $this->context );
284
			} else {
285
				// ...otherwise treat it as an article view. The article
286
				// may still be a wikipage redirect to another article or URL.
287
				$article = $this->initializeArticle();
288
				if ( is_object( $article ) ) {
289
					$this->performAction( $article, $requestTitle );
290
				} elseif ( is_string( $article ) ) {
291
					$output->redirect( $article );
292
				} else {
293
					throw new MWException( "Shouldn't happen: MediaWiki::initializeArticle()"
294
						. " returned neither an object nor a URL" );
295
				}
296
			}
297
		}
298
	}
299
300
	/**
301
	 * Handle redirects for uncanonical title requests.
302
	 *
303
	 * Handles:
304
	 * - Redirect loops.
305
	 * - No title in URL.
306
	 * - $wgUsePathInfo URLs.
307
	 * - URLs with a variant.
308
	 * - Other non-standard URLs (as long as they have no extra query parameters).
309
	 *
310
	 * Behaviour:
311
	 * - Normalise title values:
312
	 *   /wiki/Foo%20Bar -> /wiki/Foo_Bar
313
	 * - Normalise empty title:
314
	 *   /wiki/ -> /wiki/Main
315
	 *   /w/index.php?title= -> /wiki/Main
316
	 * - Normalise non-standard title urls:
317
	 *   /w/index.php?title=Foo_Bar -> /wiki/Foo_Bar
318
	 * - Don't redirect anything with query parameters other than 'title' or 'action=view'.
319
	 *
320
	 * @param Title $title
321
	 * @return bool True if a redirect was set.
322
	 * @throws HttpError
323
	 */
324
	private function tryNormaliseRedirect( Title $title ) {
325
		$request = $this->context->getRequest();
326
		$output = $this->context->getOutput();
327
328
		if ( $request->getVal( 'action', 'view' ) != 'view'
329
			|| $request->wasPosted()
330
			|| count( $request->getValueNames( [ 'action', 'title' ] ) )
331
			|| !Hooks::run( 'TestCanonicalRedirect', [ $request, $title, $output ] )
332
		) {
333
			return false;
334
		}
335
336
		if ( $title->isSpecialPage() ) {
337
			list( $name, $subpage ) = SpecialPageFactory::resolveAlias( $title->getDBkey() );
338
			if ( $name ) {
339
				$title = SpecialPage::getTitleFor( $name, $subpage );
340
			}
341
		}
342
		// Redirect to canonical url, make it a 301 to allow caching
343
		$targetUrl = wfExpandUrl( $title->getFullURL(), PROTO_CURRENT );
344
345
		if ( $targetUrl != $request->getFullRequestURL() ) {
346
			$output->setCdnMaxage( 1200 );
347
			$output->redirect( $targetUrl, '301' );
348
			return true;
349
		}
350
351
		// If there is no title, or the title is in a non-standard encoding, we demand
352
		// a redirect. If cgi somehow changed the 'title' query to be non-standard while
353
		// the url is standard, the server is misconfigured.
354
		if ( $request->getVal( 'title' ) === null
355
			|| $title->getPrefixedDBkey() != $request->getVal( 'title' )
356
		) {
357
			$message = "Redirect loop detected!\n\n" .
358
				"This means the wiki got confused about what page was " .
359
				"requested; this sometimes happens when moving a wiki " .
360
				"to a new server or changing the server configuration.\n\n";
361
362
			if ( $this->config->get( 'UsePathInfo' ) ) {
363
				$message .= "The wiki is trying to interpret the page " .
364
					"title from the URL path portion (PATH_INFO), which " .
365
					"sometimes fails depending on the web server. Try " .
366
					"setting \"\$wgUsePathInfo = false;\" in your " .
367
					"LocalSettings.php, or check that \$wgArticlePath " .
368
					"is correct.";
369
			} else {
370
				$message .= "Your web server was detected as possibly not " .
371
					"supporting URL path components (PATH_INFO) correctly; " .
372
					"check your LocalSettings.php for a customized " .
373
					"\$wgArticlePath setting and/or toggle \$wgUsePathInfo " .
374
					"to true.";
375
			}
376
			throw new HttpError( 500, $message );
377
		}
378
		return false;
379
	}
380
381
	/**
382
	 * Initialize the main Article object for "standard" actions (view, etc)
383
	 * Create an Article object for the page, following redirects if needed.
384
	 *
385
	 * @return Article|string An Article, or a string to redirect to another URL
386
	 */
387
	private function initializeArticle() {
388
		$title = $this->context->getTitle();
389
		if ( $this->context->canUseWikiPage() ) {
390
			// Try to use request context wiki page, as there
391
			// is already data from db saved in per process
392
			// cache there from this->getAction() call.
393
			$page = $this->context->getWikiPage();
394
		} else {
395
			// This case should not happen, but just in case.
396
			// @TODO: remove this or use an exception
397
			$page = WikiPage::factory( $title );
398
			$this->context->setWikiPage( $page );
399
			wfWarn( "RequestContext::canUseWikiPage() returned false" );
400
		}
401
402
		// Make GUI wrapper for the WikiPage
403
		$article = Article::newFromWikiPage( $page, $this->context );
404
405
		// Skip some unnecessary code if the content model doesn't support redirects
406
		if ( !ContentHandler::getForTitle( $title )->supportsRedirects() ) {
407
			return $article;
408
		}
409
410
		$request = $this->context->getRequest();
411
412
		// Namespace might change when using redirects
413
		// Check for redirects ...
414
		$action = $request->getVal( 'action', 'view' );
415
		$file = ( $page instanceof WikiFilePage ) ? $page->getFile() : null;
416
		if ( ( $action == 'view' || $action == 'render' ) // ... for actions that show content
417
			&& !$request->getVal( 'oldid' ) // ... and are not old revisions
418
			&& !$request->getVal( 'diff' ) // ... and not when showing diff
419
			&& $request->getVal( 'redirect' ) != 'no' // ... unless explicitly told not to
420
			// ... and the article is not a non-redirect image page with associated file
421
			&& !( is_object( $file ) && $file->exists() && !$file->getRedirected() )
422
		) {
423
			// Give extensions a change to ignore/handle redirects as needed
424
			$ignoreRedirect = $target = false;
425
426
			Hooks::run( 'InitializeArticleMaybeRedirect',
427
				[ &$title, &$request, &$ignoreRedirect, &$target, &$article ] );
428
			$page = $article->getPage(); // reflect any hook changes
429
430
			// Follow redirects only for... redirects.
431
			// If $target is set, then a hook wanted to redirect.
432
			if ( !$ignoreRedirect && ( $target || $page->isRedirect() ) ) {
433
				// Is the target already set by an extension?
434
				$target = $target ? $target : $page->followRedirect();
435
				if ( is_string( $target ) ) {
436
					if ( !$this->config->get( 'DisableHardRedirects' ) ) {
437
						// we'll need to redirect
438
						return $target;
439
					}
440
				}
441
				if ( is_object( $target ) ) {
442
					// Rewrite environment to redirected article
443
					$rpage = WikiPage::factory( $target );
444
					$rpage->loadPageData();
445
					if ( $rpage->exists() || ( is_object( $file ) && !$file->isLocal() ) ) {
446
						$rarticle = Article::newFromWikiPage( $rpage, $this->context );
447
						$rarticle->setRedirectedFrom( $title );
448
449
						$article = $rarticle;
450
						$this->context->setTitle( $target );
451
						$this->context->setWikiPage( $article->getPage() );
452
					}
453
				}
454
			} else {
455
				// Article may have been changed by hook
456
				$this->context->setTitle( $article->getTitle() );
457
				$this->context->setWikiPage( $article->getPage() );
458
			}
459
		}
460
461
		return $article;
462
	}
463
464
	/**
465
	 * Perform one of the "standard" actions
466
	 *
467
	 * @param Page $page
468
	 * @param Title $requestTitle The original title, before any redirects were applied
469
	 */
470
	private function performAction( Page $page, Title $requestTitle ) {
471
		$request = $this->context->getRequest();
472
		$output = $this->context->getOutput();
473
		$title = $this->context->getTitle();
474
		$user = $this->context->getUser();
475
476
		if ( !Hooks::run( 'MediaWikiPerformAction',
477
				[ $output, $page, $title, $user, $request, $this ] )
478
		) {
479
			return;
480
		}
481
482
		$act = $this->getAction();
483
		$action = Action::factory( $act, $page, $this->context );
484
485
		if ( $action instanceof Action ) {
486
			// Narrow DB query expectations for this HTTP request
487
			$trxLimits = $this->config->get( 'TrxProfilerLimits' );
488
			$trxProfiler = Profiler::instance()->getTransactionProfiler();
489
			if ( $request->wasPosted() && !$action->doesWrites() ) {
490
				$trxProfiler->setExpectations( $trxLimits['POST-nonwrite'], __METHOD__ );
491
				$request->markAsSafeRequest();
492
			}
493
494
			# Let CDN cache things if we can purge them.
495
			if ( $this->config->get( 'UseSquid' ) &&
496
				in_array(
497
					// Use PROTO_INTERNAL because that's what getCdnUrls() uses
498
					wfExpandUrl( $request->getRequestURL(), PROTO_INTERNAL ),
499
					$requestTitle->getCdnUrls()
500
				)
501
			) {
502
				$output->setCdnMaxage( $this->config->get( 'SquidMaxage' ) );
503
			}
504
505
			$action->show();
506
			return;
507
		}
508
509
		if ( Hooks::run( 'UnknownAction', [ $request->getVal( 'action', 'view' ), $page ] ) ) {
510
			$output->setStatusCode( 404 );
511
			$output->showErrorPage( 'nosuchaction', 'nosuchactiontext' );
512
		}
513
	}
514
515
	/**
516
	 * Run the current MediaWiki instance; index.php just calls this
517
	 */
518
	public function run() {
519
		try {
520
			$this->setDBProfilingAgent();
521
			try {
522
				$this->main();
523
			} catch ( ErrorPageError $e ) {
524
				// Bug 62091: while exceptions are convenient to bubble up GUI errors,
525
				// they are not internal application faults. As with normal requests, this
526
				// should commit, print the output, do deferred updates, jobs, and profiling.
527
				$this->doPreOutputCommit();
528
				$e->report(); // display the GUI error
529
			}
530
		} catch ( Exception $e ) {
531
			$context = $this->context;
532
			$action = $context->getRequest()->getVal( 'action', 'view' );
533
			if (
534
				$e instanceof DBConnectionError &&
535
				$context->hasTitle() &&
536
				$context->getTitle()->canExist() &&
537
				in_array( $action, [ 'view', 'history' ], true ) &&
538
				HTMLFileCache::useFileCache( $this->context, HTMLFileCache::MODE_OUTAGE )
539
			) {
540
				// Try to use any (even stale) file during outages...
541
				$cache = new HTMLFileCache( $context->getTitle(), 'view' );
542
				if ( $cache->isCached() ) {
543
					$cache->loadFromFileCache( $context, HTMLFileCache::MODE_OUTAGE );
544
					print MWExceptionRenderer::getHTML( $e );
545
					exit;
546
				}
547
548
			}
549
550
			MWExceptionHandler::handleException( $e );
551
		}
552
553
		$this->doPostOutputShutdown( 'normal' );
554
	}
555
556
	private function setDBProfilingAgent() {
557
		$services = MediaWikiServices::getInstance();
558
		// Add a comment for easy SHOW PROCESSLIST interpretation
559
		$name = $this->context->getUser()->getName();
560
		$services->getDBLoadBalancerFactory()->setAgentName(
561
			mb_strlen( $name ) > 15 ? mb_substr( $name, 0, 15 ) . '...' : $name
562
		);
563
	}
564
565
	/**
566
	 * @see MediaWiki::preOutputCommit()
567
	 * @param callable $postCommitWork [default: null]
568
	 * @since 1.26
569
	 */
570
	public function doPreOutputCommit( callable $postCommitWork = null ) {
571
		self::preOutputCommit( $this->context, $postCommitWork );
572
	}
573
574
	/**
575
	 * This function commits all DB changes as needed before
576
	 * the user can receive a response (in case commit fails)
577
	 *
578
	 * @param IContextSource $context
579
	 * @param callable $postCommitWork [default: null]
580
	 * @since 1.27
581
	 */
582
	public static function preOutputCommit(
583
		IContextSource $context, callable $postCommitWork = null
584
	) {
585
		// Either all DBs should commit or none
586
		ignore_user_abort( true );
587
588
		$config = $context->getConfig();
589
		$request = $context->getRequest();
590
		$output = $context->getOutput();
591
		$lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
592
593
		// Commit all changes
594
		$lbFactory->commitMasterChanges(
595
			__METHOD__,
596
			// Abort if any transaction was too big
597
			[ 'maxWriteDuration' => $config->get( 'MaxUserDBWriteDuration' ) ]
598
		);
599
		wfDebug( __METHOD__ . ': primary transaction round committed' );
600
601
		// Run updates that need to block the user or affect output (this is the last chance)
602
		DeferredUpdates::doUpdates( 'enqueue', DeferredUpdates::PRESEND );
603
		wfDebug( __METHOD__ . ': pre-send deferred updates completed' );
604
605
		// Decide when clients block on ChronologyProtector DB position writes
606
		$urlDomainDistance = (
607
			$request->wasPosted() &&
608
			$output->getRedirect() &&
609
			$lbFactory->hasOrMadeRecentMasterChanges( INF )
610
		) ? self::getUrlDomainDistance( $output->getRedirect(), $context ) : false;
611
612
		if ( $urlDomainDistance === 'local' || $urlDomainDistance === 'remote' ) {
613
			// OutputPage::output() will be fast; $postCommitWork will not be useful for
614
			// masking the latency of syncing DB positions accross all datacenters synchronously.
615
			// Instead, make use of the RTT time of the client follow redirects.
616
			$flags = $lbFactory::SHUTDOWN_CHRONPROT_ASYNC;
617
			$cpPosTime = microtime( true );
618
			// Client's next request should see 1+ positions with this DBMasterPos::asOf() time
619
			if ( $urlDomainDistance === 'local' ) {
620
				// Client will stay on this domain, so set an unobtrusive cookie
621
				$expires = time() + ChronologyProtector::POSITION_TTL;
622
				$options = [ 'prefix' => '' ];
623
				$request->response()->setCookie( 'cpPosTime', $cpPosTime, $expires, $options );
624
			} else {
625
				// Cookies may not work across wiki domains, so use a URL parameter
626
				$safeUrl = $lbFactory->appendPreShutdownTimeAsQuery(
627
					$output->getRedirect(),
628
					$cpPosTime
629
				);
630
				$output->redirect( $safeUrl );
631
			}
632
		} else {
633
			// OutputPage::output() is fairly slow; run it in $postCommitWork to mask
634
			// the latency of syncing DB positions accross all datacenters synchronously
635
			$flags = $lbFactory::SHUTDOWN_CHRONPROT_SYNC;
636
			if ( $lbFactory->hasOrMadeRecentMasterChanges( INF ) ) {
637
				$cpPosTime = microtime( true );
638
				// Set a cookie in case the DB position store cannot sync accross datacenters.
639
				// This will at least cover the common case of the user staying on the domain.
640
				$expires = time() + ChronologyProtector::POSITION_TTL;
641
				$options = [ 'prefix' => '' ];
642
				$request->response()->setCookie( 'cpPosTime', $cpPosTime, $expires, $options );
643
			}
644
		}
645
		// Record ChronologyProtector positions for DBs affected in this request at this point
646
		$lbFactory->shutdown( $flags, $postCommitWork );
647
		wfDebug( __METHOD__ . ': LBFactory shutdown completed' );
648
649
		// Set a cookie to tell all CDN edge nodes to "stick" the user to the DC that handles this
650
		// POST request (e.g. the "master" data center). Also have the user briefly bypass CDN so
651
		// ChronologyProtector works for cacheable URLs.
652
		if ( $request->wasPosted() && $lbFactory->hasOrMadeRecentMasterChanges() ) {
653
			$expires = time() + $config->get( 'DataCenterUpdateStickTTL' );
654
			$options = [ 'prefix' => '' ];
655
			$request->response()->setCookie( 'UseDC', 'master', $expires, $options );
656
			$request->response()->setCookie( 'UseCDNCache', 'false', $expires, $options );
657
		}
658
659
		// Avoid letting a few seconds of replica DB lag cause a month of stale data. This logic is
660
		// also intimately related to the value of $wgCdnReboundPurgeDelay.
661
		if ( $lbFactory->laggedReplicaUsed() ) {
662
			$maxAge = $config->get( 'CdnMaxageLagged' );
663
			$output->lowerCdnMaxage( $maxAge );
664
			$request->response()->header( "X-Database-Lagged: true" );
665
			wfDebugLog( 'replication', "Lagged DB used; CDN cache TTL limited to $maxAge seconds" );
666
		}
667
668
		// Avoid long-term cache pollution due to message cache rebuild timeouts (T133069)
669
		if ( MessageCache::singleton()->isDisabled() ) {
670
			$maxAge = $config->get( 'CdnMaxageSubstitute' );
671
			$output->lowerCdnMaxage( $maxAge );
672
			$request->response()->header( "X-Response-Substitute: true" );
673
		}
674
	}
675
676
	/**
677
	 * @param string $url
678
	 * @param IContextSource $context
679
	 * @return string Either "local", "remote" if in the farm, "external" otherwise
680
	 */
681
	private static function getUrlDomainDistance( $url, IContextSource $context ) {
682
		static $relevantKeys = [ 'host' => true, 'port' => true ];
683
684
		$infoCandidate = wfParseUrl( $url );
685
		if ( $infoCandidate === false ) {
686
			return 'external';
687
		}
688
689
		$infoCandidate = array_intersect_key( $infoCandidate, $relevantKeys );
690
		$clusterHosts = array_merge(
691
			// Local wiki host (the most common case)
692
			[ $context->getConfig()->get( 'CanonicalServer' ) ],
693
			// Any local/remote wiki virtual hosts for this wiki farm
694
			$context->getConfig()->get( 'LocalVirtualHosts' )
695
		);
696
697
		foreach ( $clusterHosts as $i => $clusterHost ) {
698
			$parseUrl = wfParseUrl( $clusterHost );
699
			if ( !$parseUrl ) {
700
				continue;
701
			}
702
			$infoHost = array_intersect_key( $parseUrl, $relevantKeys );
703
			if ( $infoCandidate === $infoHost ) {
704
				return ( $i === 0 ) ? 'local' : 'remote';
705
			}
706
		}
707
708
		return 'external';
709
	}
710
711
	/**
712
	 * This function does work that can be done *after* the
713
	 * user gets the HTTP response so they don't block on it
714
	 *
715
	 * This manages deferred updates, job insertion,
716
	 * final commit, and the logging of profiling data
717
	 *
718
	 * @param string $mode Use 'fast' to always skip job running
719
	 * @since 1.26
720
	 */
721
	public function doPostOutputShutdown( $mode = 'normal' ) {
722
		$timing = $this->context->getTiming();
723
		$timing->mark( 'requestShutdown' );
724
725
		// Show visible profiling data if enabled (which cannot be post-send)
726
		Profiler::instance()->logDataPageOutputOnly();
727
728
		$callback = function () use ( $mode ) {
729
			try {
730
				$this->restInPeace( $mode );
731
			} catch ( Exception $e ) {
732
				MWExceptionHandler::handleException( $e );
733
			}
734
		};
735
736
		// Defer everything else...
737
		if ( function_exists( 'register_postsend_function' ) ) {
738
			// https://github.com/facebook/hhvm/issues/1230
739
			register_postsend_function( $callback );
740
		} else {
741
			if ( function_exists( 'fastcgi_finish_request' ) ) {
742
				fastcgi_finish_request();
743
			} else {
744
				// Either all DB and deferred updates should happen or none.
745
				// The latter should not be cancelled due to client disconnect.
746
				ignore_user_abort( true );
747
			}
748
749
			$callback();
750
		}
751
	}
752
753
	private function main() {
754
		global $wgTitle;
755
756
		$output = $this->context->getOutput();
757
		$request = $this->context->getRequest();
758
759
		// Send Ajax requests to the Ajax dispatcher.
760
		if ( $this->config->get( 'UseAjax' ) && $request->getVal( 'action' ) === 'ajax' ) {
761
			// Set a dummy title, because $wgTitle == null might break things
762
			$title = Title::makeTitle( NS_SPECIAL, 'Badtitle/performing an AJAX call in '
763
				. __METHOD__
764
			);
765
			$this->context->setTitle( $title );
766
			$wgTitle = $title;
767
768
			$dispatcher = new AjaxDispatcher( $this->config );
769
			$dispatcher->performAction( $this->context->getUser() );
770
771
			return;
772
		}
773
774
		// Get title from request parameters,
775
		// is set on the fly by parseTitle the first time.
776
		$title = $this->getTitle();
777
		$action = $this->getAction();
778
		$wgTitle = $title;
779
780
		// Set DB query expectations for this HTTP request
781
		$trxLimits = $this->config->get( 'TrxProfilerLimits' );
782
		$trxProfiler = Profiler::instance()->getTransactionProfiler();
783
		$trxProfiler->setLogger( LoggerFactory::getInstance( 'DBPerformance' ) );
784
		if ( $request->hasSafeMethod() ) {
785
			$trxProfiler->setExpectations( $trxLimits['GET'], __METHOD__ );
786
		} else {
787
			$trxProfiler->setExpectations( $trxLimits['POST'], __METHOD__ );
788
		}
789
790
		// If the user has forceHTTPS set to true, or if the user
791
		// is in a group requiring HTTPS, or if they have the HTTPS
792
		// preference set, redirect them to HTTPS.
793
		// Note: Do this after $wgTitle is setup, otherwise the hooks run from
794
		// isLoggedIn() will do all sorts of weird stuff.
795
		if (
796
			$request->getProtocol() == 'http' &&
797
			// switch to HTTPS only when supported by the server
798
			preg_match( '#^https://#', wfExpandUrl( $request->getRequestURL(), PROTO_HTTPS ) ) &&
799
			(
800
				$request->getSession()->shouldForceHTTPS() ||
801
				// Check the cookie manually, for paranoia
802
				$request->getCookie( 'forceHTTPS', '' ) ||
803
				// check for prefixed version that was used for a time in older MW versions
804
				$request->getCookie( 'forceHTTPS' ) ||
805
				// Avoid checking the user and groups unless it's enabled.
806
				(
807
					$this->context->getUser()->isLoggedIn()
808
					&& $this->context->getUser()->requiresHTTPS()
809
				)
810
			)
811
		) {
812
			$oldUrl = $request->getFullRequestURL();
813
			$redirUrl = preg_replace( '#^http://#', 'https://', $oldUrl );
814
815
			// ATTENTION: This hook is likely to be removed soon due to overall design of the system.
816
			if ( Hooks::run( 'BeforeHttpsRedirect', [ $this->context, &$redirUrl ] ) ) {
817
818
				if ( $request->wasPosted() ) {
819
					// This is weird and we'd hope it almost never happens. This
820
					// means that a POST came in via HTTP and policy requires us
821
					// redirecting to HTTPS. It's likely such a request is going
822
					// to fail due to post data being lost, but let's try anyway
823
					// and just log the instance.
824
825
					// @todo FIXME: See if we could issue a 307 or 308 here, need
826
					// to see how clients (automated & browser) behave when we do
827
					wfDebugLog( 'RedirectedPosts', "Redirected from HTTP to HTTPS: $oldUrl" );
828
				}
829
				// Setup dummy Title, otherwise OutputPage::redirect will fail
830
				$title = Title::newFromText( 'REDIR', NS_MAIN );
831
				$this->context->setTitle( $title );
832
				// Since we only do this redir to change proto, always send a vary header
833
				$output->addVaryHeader( 'X-Forwarded-Proto' );
834
				$output->redirect( $redirUrl );
835
				$output->output();
836
837
				return;
838
			}
839
		}
840
841
		if ( $title->canExist() && HTMLFileCache::useFileCache( $this->context ) ) {
842
			// Try low-level file cache hit
843
			$cache = new HTMLFileCache( $title, $action );
0 ignored issues
show
It seems like $title defined by $this->getTitle() on line 776 can be null; however, HTMLFileCache::__construct() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
844
			if ( $cache->isCacheGood( /* Assume up to date */ ) ) {
845
				// Check incoming headers to see if client has this cached
846
				$timestamp = $cache->cacheTimestamp();
847
				if ( !$output->checkLastModified( $timestamp ) ) {
848
					$cache->loadFromFileCache( $this->context );
849
				}
850
				// Do any stats increment/watchlist stuff, assuming user is viewing the
851
				// latest revision (which should always be the case for file cache)
852
				$this->context->getWikiPage()->doViewUpdates( $this->context->getUser() );
853
				// Tell OutputPage that output is taken care of
854
				$output->disable();
855
856
				return;
857
			}
858
		}
859
860
		// Actually do the work of the request and build up any output
861
		$this->performRequest();
862
863
		// GUI-ify and stash the page output in MediaWiki::doPreOutputCommit() while
864
		// ChronologyProtector synchronizes DB positions or slaves accross all datacenters.
865
		$buffer = null;
866
		$outputWork = function () use ( $output, &$buffer ) {
867
			if ( $buffer === null ) {
868
				$buffer = $output->output( true );
869
			}
870
871
			return $buffer;
872
		};
873
874
		// Now commit any transactions, so that unreported errors after
875
		// output() don't roll back the whole DB transaction and so that
876
		// we avoid having both success and error text in the response
877
		$this->doPreOutputCommit( $outputWork );
878
879
		// Now send the actual output
880
		print $outputWork();
881
	}
882
883
	/**
884
	 * Ends this task peacefully
885
	 * @param string $mode Use 'fast' to always skip job running
886
	 */
887
	public function restInPeace( $mode = 'fast' ) {
888
		$lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
889
		// Assure deferred updates are not in the main transaction
890
		$lbFactory->commitMasterChanges( __METHOD__ );
891
892
		// Loosen DB query expectations since the HTTP client is unblocked
893
		$trxProfiler = Profiler::instance()->getTransactionProfiler();
894
		$trxProfiler->resetExpectations();
895
		$trxProfiler->setExpectations(
896
			$this->config->get( 'TrxProfilerLimits' )['PostSend'],
897
			__METHOD__
898
		);
899
900
		// Do any deferred jobs
901
		DeferredUpdates::doUpdates( 'enqueue' );
902
		DeferredUpdates::setImmediateMode( true );
903
904
		// Make sure any lazy jobs are pushed
905
		JobQueueGroup::pushLazyJobs();
906
907
		// Now that everything specific to this request is done,
908
		// try to occasionally run jobs (if enabled) from the queues
909
		if ( $mode === 'normal' ) {
910
			$this->triggerJobs();
911
		}
912
913
		// Log profiling data, e.g. in the database or UDP
914
		wfLogProfilingData();
915
916
		// Commit and close up!
917
		$lbFactory->commitMasterChanges( __METHOD__ );
918
		$lbFactory->shutdown( LBFactory::SHUTDOWN_NO_CHRONPROT );
919
920
		wfDebug( "Request ended normally\n" );
921
	}
922
923
	/**
924
	 * Potentially open a socket and sent an HTTP request back to the server
925
	 * to run a specified number of jobs. This registers a callback to cleanup
926
	 * the socket once it's done.
927
	 */
928
	public function triggerJobs() {
929
		$jobRunRate = $this->config->get( 'JobRunRate' );
930
		if ( $this->getTitle()->isSpecial( 'RunJobs' ) ) {
931
			return; // recursion guard
932
		} elseif ( $jobRunRate <= 0 || wfReadOnly() ) {
933
			return;
934
		}
935
936
		if ( $jobRunRate < 1 ) {
937
			$max = mt_getrandmax();
938
			if ( mt_rand( 0, $max ) > $max * $jobRunRate ) {
939
				return; // the higher the job run rate, the less likely we return here
940
			}
941
			$n = 1;
942
		} else {
943
			$n = intval( $jobRunRate );
944
		}
945
946
		$runJobsLogger = LoggerFactory::getInstance( 'runJobs' );
947
948
		// Fall back to running the job(s) while the user waits if needed
949
		if ( !$this->config->get( 'RunJobsAsync' ) ) {
950
			$runner = new JobRunner( $runJobsLogger );
951
			$runner->run( [ 'maxJobs' => $n ] );
952
			return;
953
		}
954
955
		// Do not send request if there are probably no jobs
956
		try {
957
			$group = JobQueueGroup::singleton();
958
			if ( !$group->queuesHaveJobs( JobQueueGroup::TYPE_DEFAULT ) ) {
959
				return;
960
			}
961
		} catch ( JobQueueError $e ) {
962
			MWExceptionHandler::logException( $e );
963
			return; // do not make the site unavailable
964
		}
965
966
		$query = [ 'title' => 'Special:RunJobs',
967
			'tasks' => 'jobs', 'maxjobs' => $n, 'sigexpiry' => time() + 5 ];
968
		$query['signature'] = SpecialRunJobs::getQuerySignature(
969
			$query, $this->config->get( 'SecretKey' ) );
970
971
		$errno = $errstr = null;
972
		$info = wfParseUrl( $this->config->get( 'CanonicalServer' ) );
973
		$host = $info ? $info['host'] : null;
974
		$port = 80;
975
		if ( isset( $info['scheme'] ) && $info['scheme'] == 'https' ) {
976
			$host = "tls://" . $host;
977
			$port = 443;
978
		}
979
		if ( isset( $info['port'] ) ) {
980
			$port = $info['port'];
981
		}
982
983
		MediaWiki\suppressWarnings();
984
		$sock = $host ? fsockopen(
985
			$host,
986
			$port,
987
			$errno,
988
			$errstr,
989
			// If it takes more than 100ms to connect to ourselves there is a problem...
990
			0.100
991
		) : false;
992
		MediaWiki\restoreWarnings();
993
994
		$invokedWithSuccess = true;
995
		if ( $sock ) {
996
			$special = SpecialPageFactory::getPage( 'RunJobs' );
997
			$url = $special->getPageTitle()->getCanonicalURL( $query );
998
			$req = (
999
				"POST $url HTTP/1.1\r\n" .
1000
				"Host: {$info['host']}\r\n" .
1001
				"Connection: Close\r\n" .
1002
				"Content-Length: 0\r\n\r\n"
1003
			);
1004
1005
			$runJobsLogger->info( "Running $n job(s) via '$url'" );
1006
			// Send a cron API request to be performed in the background.
1007
			// Give up if this takes too long to send (which should be rare).
1008
			stream_set_timeout( $sock, 2 );
1009
			$bytes = fwrite( $sock, $req );
1010
			if ( $bytes !== strlen( $req ) ) {
1011
				$invokedWithSuccess = false;
1012
				$runJobsLogger->error( "Failed to start cron API (socket write error)" );
1013
			} else {
1014
				// Do not wait for the response (the script should handle client aborts).
1015
				// Make sure that we don't close before that script reaches ignore_user_abort().
1016
				$start = microtime( true );
1017
				$status = fgets( $sock );
1018
				$sec = microtime( true ) - $start;
1019
				if ( !preg_match( '#^HTTP/\d\.\d 202 #', $status ) ) {
1020
					$invokedWithSuccess = false;
1021
					$runJobsLogger->error( "Failed to start cron API: received '$status' ($sec)" );
1022
				}
1023
			}
1024
			fclose( $sock );
1025
		} else {
1026
			$invokedWithSuccess = false;
1027
			$runJobsLogger->error( "Failed to start cron API (socket error $errno): $errstr" );
1028
		}
1029
1030
		// Fall back to running the job(s) while the user waits if needed
1031
		if ( !$invokedWithSuccess ) {
1032
			$runJobsLogger->warning( "Jobs switched to blocking; Special:RunJobs disabled" );
1033
1034
			$runner = new JobRunner( $runJobsLogger );
1035
			$runner->run( [ 'maxJobs'  => $n ] );
1036
		}
1037
	}
1038
}
1039