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