diff --git a/composer.json b/composer.json index 477bd24..4ab3bfc 100644 --- a/composer.json +++ b/composer.json @@ -18,5 +18,8 @@ "require": { "php": ">=8.1", "psr/log": "^3.0" + }, + "suggest": { + "ext-dom": "For HTMLHandler" } } diff --git a/lib/Catcher.php b/lib/Catcher.php index 1616416..454d43d 100644 --- a/lib/Catcher.php +++ b/lib/Catcher.php @@ -80,7 +80,12 @@ class Catcher { } } - if ($this->isShuttingDown || $throwable instanceof \Exception || in_array($throwable->getCode(), [ \E_ERROR, \E_PARSE, \E_CORE_ERROR, \E_COMPILE_ERROR, \E_USER_ERROR ])) { + if ( + self::isHTTPRequest() || + $this->isShuttingDown || + $throwable instanceof \Exception || + in_array($throwable->getCode(), [ \E_ERROR, \E_PARSE, \E_CORE_ERROR, \E_COMPILE_ERROR, \E_USER_ERROR ]) + ) { exit($throwable->getCode()); } } @@ -98,4 +103,8 @@ class Catcher { $this->handleError($error['type'], $error['message'], $error['file'], $error['line']); } } + + public static function isHTTPRequest() { + return (\PHP_SAPI !== 'cli' && isset($_SERVER['REQUEST_URI']) && isset($_SERVER['REQUEST_METHOD'])); + } } \ No newline at end of file diff --git a/lib/Catcher/HTMLHandler.php b/lib/Catcher/HTMLHandler.php new file mode 100644 index 0000000..8c1042c --- /dev/null +++ b/lib/Catcher/HTMLHandler.php @@ -0,0 +1,216 @@ +_getBacktraceArgFrameLimit; + } + + public function getOutputBacktrace(): bool { + return $this->_outputBacktrace; + } + + public function getOutputPrevious(): bool { + return $this->_outputPrevious; + } + + public function getOutputTime(): bool { + return $this->_outputTime; + } + + public function getTimeFormat(): bool { + return $this->_timeFormat; + } + + public function handle(\Throwable $throwable, ThrowableController $controller): bool { + $document = new \DOMDocument(); + $frag = $document->createDocumentFragment(); + + if ($this->_outputTime && $this->_timeFormat !== '') { + $p = $document->createElement('p'); + $time = $document->createElement('time'); + $time->appendChild($document->createTextNode((new \DateTime())->format($this->_timeFormat))); + $p->appendChild($time); + $frag->appendChild($p); + } + + $frag->appendChild($this->buildThrowable($document, $throwable, $controller)); + if ($this->_outputPrevious) { + $prev = $throwable->getPrevious(); + $prevController = $controller->getPrevious(); + while ($prev) { + $p = $document->createElement('p'); + $small = $document->createElement('small'); + $small->appendChild($document->createTextNode('Caused by ↴')); + $p->appendChild($small); + $frag->appendChild($p); + $frag->appendChild($this->buildThrowable($document, $prev, $prevController)); + $prev = $prev->getPrevious(); + $prevController = $prevController->getPrevious(); + } + } + + if ($this->_outputBacktrace) { + $frames = $controller->getFrames(); + $p = $document->createElement('p'); + $p->appendChild($document->createTextNode('Stack trace:')); + $frag->appendChild($p); + + if (count($frames) > 0) { + $ol = $document->createElement('ol'); + $p->appendChild($ol); + $num = 1; + foreach ($frames as $frame) { + $li = $document->createElement('li'); + $args = (!empty($frame['args']) && $this->_backtraceArgFrameLimit >= $num); + $t = ($args) ? $document->createElement('p') : $li; + + if (!empty($frame['error'])) { + $b = $document->createElement('b'); + $b->appendChild($document->createTextNode($frame['error'])); + $t->appendChild($b); + $t->appendChild($document->createTextNode(' (')); + $code = $document->createElement('code'); + $code->appendChild($document->createTextNode($frame['class'])); + $t->appendChild($code); + $t->appendChild($document->createTextNode(')')); + } elseif (!empty($frame['class'])) { + $code = $document->createElement('code'); + $code->appendChild($document->createTextNode($frame['class'])); + $t->appendChild($code); + } + + $class = $frame['class'] ?? ''; + $function = $frame['function'] ?? ''; + if ($function) { + if ($class) { + $code->firstChild->textContent .= "::{$function}()"; + } else { + $code = $document->createElement('code'); + $code->appendChild($document->createTextNode("{$function}()")); + $t->appendChild($code); + } + } + + $t->appendChild($document->createTextNode(' ')); + $i = $document->createElement('i'); + $i->appendChild($document->createTextNode($frame['file'])); + $t->appendChild($i); + $t->appendChild($document->createTextNode(":{$frame['line']}")); + + if ($args) { + $li->appendChild($t); + $pre = $document->createElement('pre'); + $pre->appendChild($document->createTextNode(var_export($frame['args'], true))); + $li->appendChild($pre); + } + + $ol->appendChild($li); + } + } + } + + $this->_result = $frag; + + if (\PHP_SAPI !== 'cli' && $this->_output) { + $document->loadHTML(sprintf( + '%s%s', + (isset($_SERVER['protocol'])) ? "{$_SERVER['protocol']} " : '', + '500 Internal Server Error' + )); + $document->getElementsByTagName('body')[0]->appendChild($document->importNode($frag, true)); + + $this->sendContentTypeHeader(); + http_response_code(500); + echo $document->saveHTML(); + return (!$this->_passthrough); + } + + return false; + } + + public function setBacktraceArgFrameLimit(int $value): void { + $this->_getBacktraceArgFrameLimit = $value; + } + + public function setOutputBacktrace(bool $value): void { + $this->_outputBacktrace = $value; + } + + public function setOutputPrevious(bool $value): void { + $this->_outputPrevious = $value; + } + + public function setOutputTime(bool $value): void { + $this->_outputTime = $value; + } + + public function setTimeFormat(bool $value): void { + $this->_timeFormat = $value; + } + + protected function buildThrowable(\DOMDocument $document, \Throwable $throwable, ThrowableController $controller): \DOMElement { + $p = $document->createElement('p'); + + $class = $throwable::class; + if ($throwable instanceof \Error) { + $type = $controller->getErrorType(); + if ($type !== null) { + $b = $document->createElement('b'); + $b->appendChild($document->createTextNode($type)); + $p->appendChild($b); + $p->appendChild($document->createTextNode(' (')); + $code = $document->createElement('code'); + $code->appendChild($document->createTextNode($throwable::class)); + $p->appendChild($code); + $p->appendChild($document->createTextNode(')')); + } else { + $code = $document->createElement('code'); + $code->appendChild($document->createTextNode($throwable::class)); + $p->appendChild($code); + } + } + + $p->appendChild($document->createTextNode(': ')); + $i = $document->createElement('i'); + $i->appendChild($document->createTextNode($throwable->getMessage())); + $p->appendChild($i); + $p->appendChild($document->createTextNode(' in file ')); + $code = $document->createElement('code'); + $code->appendChild($document->createTextNode($throwable->getFile())); + $p->appendChild($code); + $p->appendChild($document->createTextNode(' on line ' . $throwable->getLine())); + return $p; + } +} \ No newline at end of file diff --git a/lib/Catcher/PlainTextHandler.php b/lib/Catcher/PlainTextHandler.php index 323d31e..0eaa487 100644 --- a/lib/Catcher/PlainTextHandler.php +++ b/lib/Catcher/PlainTextHandler.php @@ -14,7 +14,7 @@ use \Psr\Log\{ class PlainTextHandler extends ThrowableHandler implements LoggerAwareInterface { - protected static ?string $contentType = 'text/plain'; + public const CONTENT_TYPE = 'text/plain'; /** The number of backtrace frames in which to print arguments; defaults to 5 */ protected int $_backtraceArgFrameLimit = 5; diff --git a/lib/Catcher/ThrowableHandler.php b/lib/Catcher/ThrowableHandler.php index 0b0332b..1b39429 100644 --- a/lib/Catcher/ThrowableHandler.php +++ b/lib/Catcher/ThrowableHandler.php @@ -10,7 +10,7 @@ namespace Mensbeam\Framework\Catcher; abstract class ThrowableHandler { - protected static ?string $contentType = null; + public const CONTENT_TYPE = null; /** If true the handler will output data; if false it will be silent */ protected bool $_output = true; @@ -20,6 +20,12 @@ abstract class ThrowableHandler { * next handler if it successfully handles the throwable */ protected bool $_passthrough = false; + /** + * The result of the handler's processing of the throwable; most of the time this + * will be a string, but in some cases like the HTMLHandler it's a + * DOMDocumentFragment so it may be further manipulated + */ + protected mixed $_result = null; @@ -32,11 +38,7 @@ abstract class ThrowableHandler { } - - public function getContentType(): ?string { - return static::$contentType; - } public function getOutput(): bool { return $this->_output; @@ -46,6 +48,10 @@ abstract class ThrowableHandler { return $this->_passthrough; } + public function getResult(): mixed { + return $this->_result; + } + abstract public function handle(\Throwable $throwable, ThrowableController $controller): bool; public function setOutput(bool $value): void { @@ -61,6 +67,6 @@ abstract class ThrowableHandler { return; } - header('Content-Type: ' . static::$contentType); + header('Content-Type: ' . static::CONTENT_TYPE); } } \ No newline at end of file