1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21use MediaWiki\MediaWikiServices;
22use Wikimedia\AtEase;
23use Wikimedia\Rdbms\DBConnectionError;
24use Wikimedia\Rdbms\DBExpectedError;
25use Wikimedia\Rdbms\DBReadOnlyError;
26use Wikimedia\RequestTimeout\RequestTimeoutException;
27
28/**
29 * Class to expose exceptions to the client (API bots, users, admins using CLI scripts)
30 * @since 1.28
31 */
32class MWExceptionRenderer {
33	public const AS_RAW = 1; // show as text
34	public const AS_PRETTY = 2; // show as HTML
35
36	/**
37	 * @param Throwable $e Original exception
38	 * @param int $mode MWExceptionExposer::AS_* constant
39	 * @param Throwable|null $eNew New throwable from attempting to show the first
40	 */
41	public static function output( Throwable $e, $mode, Throwable $eNew = null ) {
42		global $wgMimeType, $wgShowExceptionDetails;
43
44		if ( function_exists( 'apache_setenv' ) ) {
45			// The client should not be blocked on "post-send" updates. If apache decides that
46			// a response should be gzipped, it will wait for PHP to finish since it cannot gzip
47			// anything until it has the full response (even with "Transfer-Encoding: chunked").
48			AtEase\AtEase::suppressWarnings();
49			apache_setenv( 'no-gzip', '1' );
50			AtEase\AtEase::restoreWarnings();
51		}
52
53		if ( defined( 'MW_API' ) ) {
54			self::header( 'MediaWiki-API-Error: internal_api_error_' . get_class( $e ) );
55		}
56
57		if ( self::isCommandLine() ) {
58			self::printError( self::getText( $e ) );
59		} elseif ( $mode === self::AS_PRETTY ) {
60			self::statusHeader( 500 );
61			self::header( "Content-Type: $wgMimeType; charset=UTF-8" );
62			ob_start();
63			if ( $e instanceof DBConnectionError ) {
64				self::reportOutageHTML( $e );
65			} else {
66				self::reportHTML( $e );
67			}
68			self::header( "Content-Length: " . ob_get_length() );
69			ob_end_flush();
70		} else {
71			ob_start();
72			self::statusHeader( 500 );
73			self::header( "Content-Type: $wgMimeType; charset=UTF-8" );
74			if ( $eNew ) {
75				$message = "MediaWiki internal error.\n\n";
76				if ( $wgShowExceptionDetails ) {
77					$message .= 'Original exception: ' .
78						MWExceptionHandler::getLogMessage( $e ) .
79						"\nBacktrace:\n" . MWExceptionHandler::getRedactedTraceAsString( $e ) .
80						"\n\nException caught inside exception handler: " .
81							MWExceptionHandler::getLogMessage( $eNew ) .
82						"\nBacktrace:\n" . MWExceptionHandler::getRedactedTraceAsString( $eNew );
83				} else {
84					$message .= 'Original exception: ' .
85						MWExceptionHandler::getPublicLogMessage( $e );
86					$message .= "\n\nException caught inside exception handler.\n\n" .
87						self::getShowBacktraceError( $e );
88				}
89				$message .= "\n";
90			} elseif ( $wgShowExceptionDetails ) {
91				$message = MWExceptionHandler::getLogMessage( $e ) .
92					"\nBacktrace:\n" .
93					MWExceptionHandler::getRedactedTraceAsString( $e ) . "\n";
94			} else {
95				$message = MWExceptionHandler::getPublicLogMessage( $e );
96			}
97			print nl2br( htmlspecialchars( $message ) ) . "\n";
98			self::header( "Content-Length: " . ob_get_length() );
99			ob_end_flush();
100		}
101	}
102
103	/**
104	 * @param Throwable $e
105	 * @return bool Should the throwable use $wgOut to output the error?
106	 */
107	private static function useOutputPage( Throwable $e ) {
108		// Can the extension use the Message class/wfMessage to get i18n-ed messages?
109		foreach ( $e->getTrace() as $frame ) {
110			if ( isset( $frame['class'] ) && $frame['class'] === LocalisationCache::class ) {
111				return false;
112			}
113		}
114
115		// Don't even bother with OutputPage if there's no Title context set,
116		// (e.g. we're in RL code on load.php) - the Skin system (and probably
117		// most of MediaWiki) won't work.
118
119		return (
120			!empty( $GLOBALS['wgFullyInitialised'] ) &&
121			!empty( $GLOBALS['wgOut'] ) &&
122			RequestContext::getMain()->getTitle() &&
123			!defined( 'MEDIAWIKI_INSTALL' ) &&
124			// Don't send a skinned HTTP 500 page to API clients.
125			!defined( 'MW_API' )
126		);
127	}
128
129	/**
130	 * Output the throwable report using HTML
131	 *
132	 * @param Throwable $e
133	 */
134	private static function reportHTML( Throwable $e ) {
135		global $wgOut, $wgSitename;
136
137		if ( self::useOutputPage( $e ) ) {
138			$wgOut->prepareErrorPage( self::getExceptionTitle( $e ) );
139
140			// Show any custom GUI message before the details
141			$customMessage = self::getCustomMessage( $e );
142			if ( $customMessage !== null ) {
143				$wgOut->addHTML( Html::element( 'p', [], $customMessage ) );
144			}
145			$wgOut->addHTML( self::getHTML( $e ) );
146
147			$wgOut->output();
148		} else {
149			self::header( 'Content-Type: text/html; charset=utf-8' );
150			$pageTitle = self::msg( 'internalerror', 'Internal error' );
151			echo "<!DOCTYPE html>\n" .
152				'<html><head>' .
153				// Mimick OutputPage::setPageTitle behaviour
154				'<title>' .
155				htmlspecialchars( self::msg( 'pagetitle', "$1 - $wgSitename", $pageTitle ) ) .
156				'</title>' .
157				'<style>body { font-family: sans-serif; margin: 0; padding: 0.5em 2em; }</style>' .
158				"</head><body>\n";
159
160			echo self::getHTML( $e );
161
162			echo "</body></html>\n";
163		}
164	}
165
166	/**
167	 * If $wgShowExceptionDetails is true, return a HTML message with a
168	 * backtrace to the error, otherwise show a message to ask to set it to true
169	 * to show that information.
170	 *
171	 * @param Throwable $e
172	 * @return string Html to output
173	 */
174	public static function getHTML( Throwable $e ) {
175		global $wgShowExceptionDetails;
176
177		if ( $wgShowExceptionDetails ) {
178			$html = "<div class=\"errorbox mw-content-ltr\"><p>" .
179				nl2br( htmlspecialchars( MWExceptionHandler::getLogMessage( $e ) ) ) .
180				'</p><p>Backtrace:</p><p>' .
181				nl2br( htmlspecialchars( MWExceptionHandler::getRedactedTraceAsString( $e ) ) ) .
182				"</p></div>\n";
183		} else {
184			$logId = WebRequest::getRequestId();
185			$html = "<div class=\"errorbox mw-content-ltr\">" .
186				htmlspecialchars(
187					'[' . $logId . '] ' .
188					gmdate( 'Y-m-d H:i:s' ) . ": " .
189					self::msg( "internalerror-fatal-exception",
190						"Fatal exception of type $1",
191						get_class( $e ),
192						$logId,
193						MWExceptionHandler::getURL()
194				) ) . "</div>\n" .
195				"<!-- " . wordwrap( self::getShowBacktraceError( $e ), 50 ) . " -->";
196		}
197
198		return $html;
199	}
200
201	/**
202	 * Get a message from i18n
203	 *
204	 * @param string $key Message name
205	 * @param string $fallback Default message if the message cache can't be
206	 *                  called by the exception
207	 * @param mixed ...$params To pass to wfMessage()
208	 * @return string Message with arguments replaced
209	 */
210	private static function msg( $key, $fallback, ...$params ) {
211		global $wgSitename;
212
213		// FIXME: Keep logic in sync with MWException::msg.
214		try {
215			$res = wfMessage( $key, ...$params )->text();
216		} catch ( Exception $e ) {
217			$res = wfMsgReplaceArgs( $fallback, $params );
218			// If an exception happens inside message rendering,
219			// {{SITENAME}} sometimes won't be replaced.
220			$res = strtr( $res, [
221				'{{SITENAME}}' => $wgSitename,
222			] );
223		}
224		return $res;
225	}
226
227	/**
228	 * @param Throwable $e
229	 * @return string
230	 */
231	private static function getText( Throwable $e ) {
232		global $wgShowExceptionDetails;
233
234		if ( $wgShowExceptionDetails ) {
235			return MWExceptionHandler::getLogMessage( $e ) .
236				"\nBacktrace:\n" .
237				MWExceptionHandler::getRedactedTraceAsString( $e ) . "\n";
238		} else {
239			return self::getShowBacktraceError( $e ) . "\n";
240		}
241	}
242
243	/**
244	 * @param Throwable $e
245	 * @return string
246	 */
247	private static function getShowBacktraceError( Throwable $e ) {
248		$var = '$wgShowExceptionDetails = true;';
249		return "Set $var at the bottom of LocalSettings.php to show detailed debugging information.";
250	}
251
252	/**
253	 * Get the page title to be used for a given exception.
254	 *
255	 * @param Throwable $e
256	 * @return string
257	 */
258	private static function getExceptionTitle( Throwable $e ) {
259		if ( $e instanceof MWException ) {
260			return $e->getPageTitle();
261		} elseif ( $e instanceof DBReadOnlyError ) {
262			return self::msg( 'readonly', 'Database is locked' );
263		} elseif ( $e instanceof DBExpectedError ) {
264			return self::msg( 'databaseerror', 'Database error' );
265		} elseif ( $e instanceof RequestTimeoutException ) {
266			return self::msg( 'timeouterror', 'Request timeout' );
267		} else {
268			return self::msg( 'internalerror', 'Internal error' );
269		}
270	}
271
272	/**
273	 * Extract an additional user-visible message from an exception, or null if
274	 * it has none.
275	 *
276	 * @param Throwable $e
277	 * @return string|null
278	 */
279	private static function getCustomMessage( Throwable $e ) {
280		try {
281			if ( $e instanceof MessageSpecifier ) {
282				$msg = Message::newFromSpecifier( $e );
283			} elseif ( $e instanceof RequestTimeoutException ) {
284				$msg = wfMessage( 'timeouterror-text', $e->getLimit() );
285			} else {
286				return null;
287			}
288			$text = $msg->text();
289		} catch ( Exception $e2 ) {
290			return null;
291		}
292		return $text;
293	}
294
295	/**
296	 * @return bool
297	 */
298	private static function isCommandLine() {
299		return !empty( $GLOBALS['wgCommandLineMode'] );
300	}
301
302	/**
303	 * @param string $header
304	 */
305	private static function header( $header ) {
306		if ( !headers_sent() ) {
307			header( $header );
308		}
309	}
310
311	/**
312	 * @param int $code
313	 */
314	private static function statusHeader( $code ) {
315		if ( !headers_sent() ) {
316			HttpStatus::header( $code );
317		}
318	}
319
320	/**
321	 * Print a message, if possible to STDERR.
322	 * Use this in command line mode only (see isCommandLine)
323	 *
324	 * @suppress SecurityCheck-XSS
325	 * @param string $message Failure text
326	 */
327	private static function printError( $message ) {
328		// NOTE: STDERR may not be available, especially if php-cgi is used from the
329		// command line (T17602). Try to produce meaningful output anyway. Using
330		// echo may corrupt output to STDOUT though.
331		if ( defined( 'STDERR' ) ) {
332			fwrite( STDERR, $message );
333		} else {
334			echo $message;
335		}
336	}
337
338	/**
339	 * @param Throwable $e
340	 */
341	private static function reportOutageHTML( Throwable $e ) {
342		global $wgShowExceptionDetails, $wgShowHostnames, $wgSitename;
343
344		$sorry = htmlspecialchars( self::msg(
345			'dberr-problems',
346			'Sorry! This site is experiencing technical difficulties.'
347		) );
348		$again = htmlspecialchars( self::msg(
349			'dberr-again',
350			'Try waiting a few minutes and reloading.'
351		) );
352
353		if ( $wgShowHostnames ) {
354			$info = str_replace(
355				'$1',
356				Html::element( 'span', [ 'dir' => 'ltr' ], $e->getMessage() ),
357				htmlspecialchars( self::msg( 'dberr-info', '($1)' ) )
358			);
359		} else {
360			$info = htmlspecialchars( self::msg(
361				'dberr-info-hidden',
362				'(Cannot access the database)'
363			) );
364		}
365
366		MediaWikiServices::getInstance()->getMessageCache()->disable(); // no DB access
367		$html = "<!DOCTYPE html>\n" .
368				'<html><head>' .
369				'<title>' .
370				htmlspecialchars( $wgSitename ) .
371				'</title>' .
372				'<style>body { font-family: sans-serif; margin: 0; padding: 0.5em 2em; }</style>' .
373				"</head><body><h1>$sorry</h1><p>$again</p><p><small>$info</small></p>";
374
375		if ( $wgShowExceptionDetails ) {
376			$html .= '<p>Backtrace:</p><pre>' .
377				htmlspecialchars( $e->getTraceAsString() ) . '</pre>';
378		}
379
380		$html .= '</body></html>';
381		echo $html;
382	}
383}
384