Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 175 |
|
0.00% |
0 / 17 |
CRAP | |
0.00% |
0 / 1 |
MWExceptionRenderer | |
0.00% |
0 / 175 |
|
0.00% |
0 / 17 |
2862 | |
0.00% |
0 / 1 |
shouldShowExceptionDetails | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setShowExceptionDetails | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
output | |
0.00% |
0 / 44 |
|
0.00% |
0 / 1 |
132 | |||
useOutputPage | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
90 | |||
reportHTML | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
12 | |||
getHTML | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
6 | |||
msg | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
msgObj | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 | |||
getText | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
getShowBacktraceError | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getExceptionTitle | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
getCustomMessage | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
isCommandLine | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
header | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
statusHeader | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
printError | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
12 | |||
reportOutageHTML | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
12 |
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 | |
21 | use MediaWiki\Context\RequestContext; |
22 | use MediaWiki\Html\Html; |
23 | use MediaWiki\Language\RawMessage; |
24 | use MediaWiki\MainConfigNames; |
25 | use MediaWiki\MediaWikiServices; |
26 | use MediaWiki\Message\Message; |
27 | use MediaWiki\Request\WebRequest; |
28 | use Wikimedia\AtEase; |
29 | use Wikimedia\Message\MessageParam; |
30 | use Wikimedia\Message\MessageSpecifier; |
31 | use Wikimedia\Rdbms\DBConnectionError; |
32 | use Wikimedia\Rdbms\DBExpectedError; |
33 | use Wikimedia\Rdbms\DBReadOnlyError; |
34 | use Wikimedia\RequestTimeout\RequestTimeoutException; |
35 | |
36 | /** |
37 | * Class to expose exceptions to the client (API bots, users, admins using CLI scripts) |
38 | * @since 1.28 |
39 | */ |
40 | class MWExceptionRenderer { |
41 | public const AS_RAW = 1; // show as text |
42 | public const AS_PRETTY = 2; // show as HTML |
43 | |
44 | /** |
45 | * Whether to print exception details. |
46 | * |
47 | * The default is configured by $wgShowExceptionDetails. |
48 | * May be changed at runtime via MWExceptionRenderer::setShowExceptionDetails(). |
49 | * |
50 | * @see MainConfigNames::ShowExceptionDetails |
51 | * @var bool |
52 | */ |
53 | private static $showExceptionDetails = false; |
54 | |
55 | /** |
56 | * @internal For use within core wiring only. |
57 | * @return bool |
58 | */ |
59 | public static function shouldShowExceptionDetails(): bool { |
60 | return self::$showExceptionDetails; |
61 | } |
62 | |
63 | /** |
64 | * @param bool $showDetails |
65 | * @internal For use by Setup.php and other internal use cases. |
66 | */ |
67 | public static function setShowExceptionDetails( bool $showDetails ): void { |
68 | self::$showExceptionDetails = $showDetails; |
69 | } |
70 | |
71 | /** |
72 | * @param Throwable $e Original exception |
73 | * @param int $mode MWExceptionExposer::AS_* constant |
74 | * @param Throwable|null $eNew New throwable from attempting to show the first |
75 | */ |
76 | public static function output( Throwable $e, $mode, ?Throwable $eNew = null ) { |
77 | $showExceptionDetails = self::shouldShowExceptionDetails(); |
78 | if ( $e instanceof RequestTimeoutException && headers_sent() ) { |
79 | // Excimer's flag check happens on function return, so, a timeout |
80 | // can be thrown after exiting, say, `doPostOutputShutdown`, where |
81 | // headers are sent. In which case, it's probably fine not to |
82 | // report this in any user visible way. The general question of |
83 | // what to do about reporting an exception when headers have been |
84 | // sent is still unclear, but you probably don't want to |
85 | // `useOutputPage`. |
86 | return; |
87 | } |
88 | |
89 | if ( function_exists( 'apache_setenv' ) ) { |
90 | // The client should not be blocked on "post-send" updates. If apache decides that |
91 | // a response should be gzipped, it will wait for PHP to finish since it cannot gzip |
92 | // anything until it has the full response (even with "Transfer-Encoding: chunked"). |
93 | AtEase\AtEase::suppressWarnings(); |
94 | apache_setenv( 'no-gzip', '1' ); |
95 | AtEase\AtEase::restoreWarnings(); |
96 | } |
97 | |
98 | if ( defined( 'MW_API' ) ) { |
99 | self::header( 'MediaWiki-API-Error: internal_api_error_' . get_class( $e ) ); |
100 | } |
101 | |
102 | if ( self::isCommandLine() ) { |
103 | self::printError( self::getText( $e ) ); |
104 | } elseif ( $mode === self::AS_PRETTY ) { |
105 | self::statusHeader( 500 ); |
106 | ob_start(); |
107 | if ( $e instanceof DBConnectionError ) { |
108 | self::reportOutageHTML( $e ); |
109 | } else { |
110 | self::reportHTML( $e ); |
111 | } |
112 | self::header( "Content-Length: " . ob_get_length() ); |
113 | ob_end_flush(); |
114 | } else { |
115 | ob_start(); |
116 | self::statusHeader( 500 ); |
117 | self::header( 'Content-Type: text/html; charset=UTF-8' ); |
118 | if ( $eNew ) { |
119 | $message = "MediaWiki internal error.\n\n"; |
120 | if ( $showExceptionDetails ) { |
121 | $message .= 'Original exception: ' . |
122 | MWExceptionHandler::getLogMessage( $e ) . |
123 | "\nBacktrace:\n" . MWExceptionHandler::getRedactedTraceAsString( $e ) . |
124 | "\n\nException caught inside exception handler: " . |
125 | MWExceptionHandler::getLogMessage( $eNew ) . |
126 | "\nBacktrace:\n" . MWExceptionHandler::getRedactedTraceAsString( $eNew ); |
127 | } else { |
128 | $message .= 'Original exception: ' . |
129 | MWExceptionHandler::getPublicLogMessage( $e ); |
130 | $message .= "\n\nException caught inside exception handler.\n\n" . |
131 | self::getShowBacktraceError(); |
132 | } |
133 | $message .= "\n"; |
134 | } elseif ( $showExceptionDetails ) { |
135 | $message = MWExceptionHandler::getLogMessage( $e ) . |
136 | "\nBacktrace:\n" . |
137 | MWExceptionHandler::getRedactedTraceAsString( $e ) . "\n"; |
138 | } else { |
139 | $message = MWExceptionHandler::getPublicLogMessage( $e ); |
140 | } |
141 | print nl2br( htmlspecialchars( $message ) ) . "\n"; |
142 | self::header( "Content-Length: " . ob_get_length() ); |
143 | ob_end_flush(); |
144 | } |
145 | } |
146 | |
147 | /** |
148 | * @param Throwable $e |
149 | * @return bool Should the throwable use $wgOut to output the error? |
150 | */ |
151 | private static function useOutputPage( Throwable $e ) { |
152 | // Can the exception use the Message class/wfMessage to get i18n-ed messages? |
153 | foreach ( $e->getTrace() as $frame ) { |
154 | if ( isset( $frame['class'] ) && $frame['class'] === LocalisationCache::class ) { |
155 | return false; |
156 | } |
157 | } |
158 | |
159 | // Don't even bother with OutputPage if there's no Title context set, |
160 | // (e.g. we're in RL code on load.php) - the Skin system (and probably |
161 | // most of MediaWiki) won't work. |
162 | return ( |
163 | !empty( $GLOBALS['wgFullyInitialised'] ) && |
164 | !empty( $GLOBALS['wgOut'] ) && |
165 | RequestContext::getMain()->getTitle() && |
166 | !defined( 'MEDIAWIKI_INSTALL' ) && |
167 | // Don't send a skinned HTTP 500 page to API clients. |
168 | !defined( 'MW_API' ) && |
169 | !defined( 'MW_REST_API' ) |
170 | ); |
171 | } |
172 | |
173 | /** |
174 | * Output the throwable report using HTML |
175 | */ |
176 | private static function reportHTML( Throwable $e ) { |
177 | if ( self::useOutputPage( $e ) ) { |
178 | $out = RequestContext::getMain()->getOutput(); |
179 | $out->prepareErrorPage(); |
180 | $out->setPageTitleMsg( self::getExceptionTitle( $e ) ); |
181 | |
182 | // Show any custom GUI message before the details |
183 | $customMessage = self::getCustomMessage( $e ); |
184 | if ( $customMessage !== null ) { |
185 | $out->addHTML( Html::element( 'p', [], $customMessage ) ); |
186 | } |
187 | $out->addHTML( self::getHTML( $e ) ); |
188 | // Content-Type is set by OutputPage::output |
189 | $out->output(); |
190 | } else { |
191 | self::header( 'Content-Type: text/html; charset=UTF-8' ); |
192 | $pageTitle = self::msg( 'internalerror', 'Internal error' ); |
193 | echo "<!DOCTYPE html>\n" . |
194 | '<html><head>' . |
195 | // Mimic OutputPage::setPageTitle behaviour |
196 | '<title>' . |
197 | htmlspecialchars( self::msg( 'pagetitle', '$1 - MediaWiki', $pageTitle ) ) . |
198 | '</title>' . |
199 | '<meta name="color-scheme" content="light dark" />' . |
200 | '<style>body { font-family: sans-serif; margin: 0; padding: 0.5em 2em; }</style>' . |
201 | "</head><body>\n"; |
202 | |
203 | echo self::getHTML( $e ); |
204 | |
205 | echo "</body></html>\n"; |
206 | } |
207 | } |
208 | |
209 | /** |
210 | * Format an HTML message for the given exception object. |
211 | * |
212 | * @param Throwable $e |
213 | * @return string Html to output |
214 | */ |
215 | public static function getHTML( Throwable $e ) { |
216 | if ( self::shouldShowExceptionDetails() ) { |
217 | $html = '<div dir=ltr>' . Html::errorBox( "<p>" . |
218 | nl2br( htmlspecialchars( MWExceptionHandler::getLogMessage( $e ) ) ) . |
219 | '</p><p>Backtrace:</p><p>' . |
220 | nl2br( htmlspecialchars( MWExceptionHandler::getRedactedTraceAsString( $e ) ) ) . |
221 | "</p>\n" |
222 | ) . '</div>'; |
223 | } else { |
224 | $logId = WebRequest::getRequestId(); |
225 | $html = Html::errorBox( |
226 | htmlspecialchars( |
227 | '[' . $logId . '] ' . |
228 | gmdate( 'Y-m-d H:i:s' ) . ": " . |
229 | self::msg( "internalerror-fatal-exception", |
230 | "Fatal exception of type $1", |
231 | get_class( $e ), |
232 | $logId, |
233 | MWExceptionHandler::getURL() |
234 | ) ) |
235 | ) . "<!-- " . wordwrap( self::getShowBacktraceError(), 50 ) . " -->"; |
236 | } |
237 | |
238 | return $html; |
239 | } |
240 | |
241 | /** |
242 | * Get a message string from i18n |
243 | * |
244 | * @param string $key Message name |
245 | * @param string $fallback Default message if the message cache can't be |
246 | * called by the exception |
247 | * @phpcs:ignore Generic.Files.LineLength |
248 | * @param MessageParam|MessageSpecifier|string|int|float|list<MessageParam|MessageSpecifier|string|int|float> ...$params |
249 | * See Message::params() |
250 | * @return string Message with arguments replaced |
251 | */ |
252 | public static function msg( $key, $fallback, ...$params ) { |
253 | // NOTE: Keep logic in sync with MWException::msg |
254 | $res = self::msgObj( $key, $fallback, ...$params )->text(); |
255 | return strtr( $res, [ |
256 | '{{SITENAME}}' => 'MediaWiki', |
257 | ] ); |
258 | } |
259 | |
260 | /** Get a Message object from i18n. |
261 | * |
262 | * @param string $key Message name |
263 | * @param string $fallback Default message if the message cache can't be |
264 | * called by the exception |
265 | * @phpcs:ignore Generic.Files.LineLength |
266 | * @param MessageParam|MessageSpecifier|string|int|float|list<MessageParam|MessageSpecifier|string|int|float> ...$params |
267 | * See Message::params() |
268 | * @return Message|RawMessage |
269 | */ |
270 | private static function msgObj( string $key, string $fallback, ...$params ): Message { |
271 | // NOTE: Keep logic in sync with MWException::msg. |
272 | try { |
273 | $res = wfMessage( $key, ...$params ); |
274 | } catch ( Exception $e ) { |
275 | // Fallback to static message text and generic sitename. |
276 | // Avoid live config as this must work before Setup/MediaWikiServices finish. |
277 | $res = new RawMessage( $fallback, $params ); |
278 | } |
279 | // We are in an error state, best to minimize how much work we do. |
280 | $res->useDatabase( false ); |
281 | $isSafeToLoad = RequestContext::getMain()->getUser()->isSafeToLoad(); |
282 | if ( !$isSafeToLoad ) { |
283 | $res->inContentLanguage(); |
284 | } |
285 | return $res; |
286 | } |
287 | |
288 | /** |
289 | * @param Throwable $e |
290 | * @return string |
291 | */ |
292 | private static function getText( Throwable $e ) { |
293 | // XXX: do we need a parameter to control inclusion of exception details? |
294 | if ( self::shouldShowExceptionDetails() ) { |
295 | return MWExceptionHandler::getLogMessage( $e ) . |
296 | "\nBacktrace:\n" . |
297 | MWExceptionHandler::getRedactedTraceAsString( $e ) . "\n"; |
298 | } else { |
299 | return self::getShowBacktraceError() . "\n"; |
300 | } |
301 | } |
302 | |
303 | /** |
304 | * @return string |
305 | */ |
306 | private static function getShowBacktraceError() { |
307 | $var = '$wgShowExceptionDetails = true;'; |
308 | return "Set $var at the bottom of LocalSettings.php to show detailed debugging information."; |
309 | } |
310 | |
311 | /** |
312 | * Get the page title to be used for a given exception. |
313 | * |
314 | * @param Throwable $e |
315 | * @return Message |
316 | */ |
317 | private static function getExceptionTitle( Throwable $e ): Message { |
318 | if ( $e instanceof DBReadOnlyError ) { |
319 | return self::msgObj( 'readonly', 'Database is locked' ); |
320 | } elseif ( $e instanceof DBExpectedError ) { |
321 | return self::msgObj( 'databaseerror', 'Database error' ); |
322 | } elseif ( $e instanceof RequestTimeoutException ) { |
323 | return self::msgObj( 'timeouterror', 'Request timeout' ); |
324 | } else { |
325 | return self::msgObj( 'internalerror', 'Internal error' ); |
326 | } |
327 | } |
328 | |
329 | /** |
330 | * Extract an additional user-visible message from an exception, or null if |
331 | * it has none. |
332 | * |
333 | * @param Throwable $e |
334 | * @return string|null |
335 | */ |
336 | private static function getCustomMessage( Throwable $e ) { |
337 | try { |
338 | if ( $e instanceof MessageSpecifier ) { |
339 | $msg = Message::newFromSpecifier( $e ); |
340 | } elseif ( $e instanceof RequestTimeoutException ) { |
341 | $msg = wfMessage( 'timeouterror-text', $e->getLimit() ); |
342 | } else { |
343 | return null; |
344 | } |
345 | $text = $msg->text(); |
346 | } catch ( Exception $e2 ) { |
347 | return null; |
348 | } |
349 | return $text; |
350 | } |
351 | |
352 | /** |
353 | * @return bool |
354 | */ |
355 | private static function isCommandLine() { |
356 | return MW_ENTRY_POINT === 'cli'; |
357 | } |
358 | |
359 | /** |
360 | * @param string $header |
361 | */ |
362 | private static function header( $header ) { |
363 | if ( !headers_sent() ) { |
364 | header( $header ); |
365 | } |
366 | } |
367 | |
368 | /** |
369 | * @param int $code |
370 | */ |
371 | private static function statusHeader( $code ) { |
372 | if ( !headers_sent() ) { |
373 | HttpStatus::header( $code ); |
374 | } |
375 | } |
376 | |
377 | /** |
378 | * Print a message, if possible to STDERR. |
379 | * Use this in command line mode only (see isCommandLine) |
380 | * |
381 | * @suppress SecurityCheck-XSS |
382 | * @param string $message Failure text |
383 | */ |
384 | private static function printError( $message ) { |
385 | // NOTE: STDERR may not be available, especially if php-cgi is used from the |
386 | // command line (T17602). Try to produce meaningful output anyway. Using |
387 | // echo may corrupt output to STDOUT though. |
388 | if ( !defined( 'MW_PHPUNIT_TEST' ) && defined( 'STDERR' ) ) { |
389 | fwrite( STDERR, $message ); |
390 | } else { |
391 | echo $message; |
392 | } |
393 | } |
394 | |
395 | private static function reportOutageHTML( Throwable $e ) { |
396 | $mainConfig = MediaWikiServices::getInstance()->getMainConfig(); |
397 | $showExceptionDetails = $mainConfig->get( MainConfigNames::ShowExceptionDetails ); |
398 | $showHostnames = $mainConfig->get( MainConfigNames::ShowHostnames ); |
399 | $sorry = htmlspecialchars( self::msg( |
400 | 'dberr-problems', |
401 | 'Sorry! This site is experiencing technical difficulties.' |
402 | ) ); |
403 | $again = htmlspecialchars( self::msg( |
404 | 'dberr-again', |
405 | 'Try waiting a few minutes and reloading.' |
406 | ) ); |
407 | |
408 | if ( $showHostnames ) { |
409 | $info = str_replace( |
410 | '$1', |
411 | Html::element( 'span', [ 'dir' => 'ltr' ], $e->getMessage() ), |
412 | htmlspecialchars( self::msg( 'dberr-info', '($1)' ) ) |
413 | ); |
414 | } else { |
415 | $info = htmlspecialchars( self::msg( |
416 | 'dberr-info-hidden', |
417 | '(Cannot access the database)' |
418 | ) ); |
419 | } |
420 | |
421 | MediaWikiServices::getInstance()->getMessageCache()->disable(); // no DB access |
422 | $html = "<!DOCTYPE html>\n" . |
423 | '<html><head>' . |
424 | '<title>MediaWiki</title>' . |
425 | '<meta name="color-scheme" content="light dark" />' . |
426 | '<style>body { font-family: sans-serif; margin: 0; padding: 0.5em 2em; }</style>' . |
427 | "</head><body><h1>$sorry</h1><p>$again</p><p><small>$info</small></p>"; |
428 | |
429 | if ( $showExceptionDetails ) { |
430 | $html .= '<p>Backtrace:</p><pre>' . |
431 | htmlspecialchars( $e->getTraceAsString() ) . '</pre>'; |
432 | } |
433 | |
434 | $html .= '</body></html>'; |
435 | self::header( 'Content-Type: text/html; charset=UTF-8' ); |
436 | echo $html; |
437 | } |
438 | } |