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