447 lines
9.7 KiB
PHP
447 lines
9.7 KiB
PHP
<?php
|
|
|
|
namespace Web;
|
|
|
|
/**
|
|
* ErrorHandler manages error pages and exception handling
|
|
*/
|
|
class ErrorHandler
|
|
{
|
|
private array $handlers = [];
|
|
private $defaultHandler = null;
|
|
private bool $debug = false;
|
|
|
|
/**
|
|
* __construct creates a new ErrorHandler
|
|
*/
|
|
public function __construct(bool $debug = false)
|
|
{
|
|
$this->debug = $debug;
|
|
$this->registerDefaultHandlers();
|
|
}
|
|
|
|
/**
|
|
* register registers a custom error handler for a specific status code
|
|
*/
|
|
public function register(int $status, callable $handler): void
|
|
{
|
|
$this->handlers[$status] = $handler;
|
|
}
|
|
|
|
/**
|
|
* setDefaultHandler sets the default error handler for unregistered status codes
|
|
*/
|
|
public function setDefaultHandler(callable $handler): void
|
|
{
|
|
$this->defaultHandler = $handler;
|
|
}
|
|
|
|
/**
|
|
* handle handles an error with the appropriate handler
|
|
*/
|
|
public function handle(Context $context, int $status, string $message = '', ?\Exception $exception = null): void
|
|
{
|
|
if (isset($this->handlers[$status])) {
|
|
$handler = $this->handlers[$status];
|
|
$handler($context, $status, $message, $exception);
|
|
return;
|
|
}
|
|
|
|
if ($this->defaultHandler) {
|
|
($this->defaultHandler)($context, $status, $message, $exception);
|
|
return;
|
|
}
|
|
|
|
$this->renderDefaultError($context, $status, $message, $exception);
|
|
}
|
|
|
|
/**
|
|
* handleException handles uncaught exceptions
|
|
*/
|
|
public function handleException(Context $context, \Exception $exception): void
|
|
{
|
|
$status = $this->getStatusFromException($exception);
|
|
$message = $this->debug ? $exception->getMessage() : $this->getDefaultMessage($status);
|
|
|
|
if ($this->debug) {
|
|
error_log($exception->getMessage() . "\n" . $exception->getTraceAsString());
|
|
}
|
|
|
|
$this->handle($context, $status, $message, $exception);
|
|
}
|
|
|
|
/**
|
|
* getStatusFromException determines HTTP status from exception type
|
|
*/
|
|
private function getStatusFromException(\Exception $exception): int
|
|
{
|
|
if ($exception instanceof HttpException) {
|
|
return $exception->getStatusCode();
|
|
}
|
|
|
|
return match(get_class($exception)) {
|
|
'InvalidArgumentException' => 400,
|
|
'UnauthorizedException' => 401,
|
|
'ForbiddenException' => 403,
|
|
'NotFoundException' => 404,
|
|
'MethodNotAllowedException' => 405,
|
|
'ValidationException' => 422,
|
|
default => 500
|
|
};
|
|
}
|
|
|
|
/**
|
|
* registerDefaultHandlers registers built-in error handlers
|
|
*/
|
|
private function registerDefaultHandlers(): void
|
|
{
|
|
// 404 Not Found
|
|
$this->register(404, function(Context $context, int $status, string $message) {
|
|
$accept = $context->request->header('accept') ?? '';
|
|
|
|
if (str_contains($accept, 'application/json')) {
|
|
$context->json(['error' => $message ?: 'Not Found'], 404);
|
|
} else {
|
|
$html = $this->render404Page($message);
|
|
$context->html($html, 404);
|
|
}
|
|
});
|
|
|
|
// 500 Internal Server Error
|
|
$this->register(500, function(Context $context, int $status, string $message, ?\Exception $exception) {
|
|
$accept = $context->request->header('accept') ?? '';
|
|
|
|
if (str_contains($accept, 'application/json')) {
|
|
$response = ['error' => $message ?: 'Internal Server Error'];
|
|
if ($this->debug && $exception) {
|
|
$response['trace'] = $exception->getTrace();
|
|
}
|
|
$context->json($response, 500);
|
|
} else {
|
|
$html = $this->render500Page($message, $exception);
|
|
$context->html($html, 500);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* renderDefaultError renders a generic error response
|
|
*/
|
|
private function renderDefaultError(Context $context, int $status, string $message, ?\Exception $exception): void
|
|
{
|
|
$message = $message ?: $this->getDefaultMessage($status);
|
|
$accept = $context->request->header('accept') ?? '';
|
|
|
|
if (str_contains($accept, 'application/json')) {
|
|
$response = ['error' => $message];
|
|
if ($this->debug && $exception) {
|
|
$response['trace'] = $exception->getTrace();
|
|
}
|
|
$context->json($response, $status);
|
|
} else {
|
|
$html = $this->renderErrorPage($status, $message, $exception);
|
|
$context->html($html, $status);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* getDefaultMessage gets default message for status code
|
|
*/
|
|
private function getDefaultMessage(int $status): string
|
|
{
|
|
return match($status) {
|
|
400 => 'Bad Request',
|
|
401 => 'Unauthorized',
|
|
403 => 'Forbidden',
|
|
404 => 'Not Found',
|
|
405 => 'Method Not Allowed',
|
|
422 => 'Unprocessable Entity',
|
|
429 => 'Too Many Requests',
|
|
500 => 'Internal Server Error',
|
|
502 => 'Bad Gateway',
|
|
503 => 'Service Unavailable',
|
|
default => 'Error'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* render404Page renders a 404 error page
|
|
*/
|
|
private function render404Page(string $message): string
|
|
{
|
|
$message = htmlspecialchars($message ?: 'Not Found');
|
|
|
|
return <<<HTML
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>404 - Not Found</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
background: #f5f5f5;
|
|
color: #333;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 100vh;
|
|
}
|
|
.error-container {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
}
|
|
h1 {
|
|
font-size: 6rem;
|
|
margin: 0;
|
|
color: #e74c3c;
|
|
}
|
|
h2 {
|
|
font-size: 1.5rem;
|
|
margin: 1rem 0;
|
|
font-weight: normal;
|
|
}
|
|
p {
|
|
color: #666;
|
|
margin: 1rem 0;
|
|
}
|
|
a {
|
|
color: #3498db;
|
|
text-decoration: none;
|
|
}
|
|
a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="error-container">
|
|
<h1>404</h1>
|
|
<h2>{$message}</h2>
|
|
<p>The page you are looking for could not be found.</p>
|
|
<a href="/">Go to Homepage</a>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
}
|
|
|
|
/**
|
|
* render500Page renders a 500 error page
|
|
*/
|
|
private function render500Page(string $message, ?\Exception $exception): string
|
|
{
|
|
$message = htmlspecialchars($message ?: 'Internal Server Error');
|
|
$debugInfo = '';
|
|
|
|
if ($this->debug && $exception) {
|
|
$exceptionClass = get_class($exception);
|
|
$file = htmlspecialchars($exception->getFile());
|
|
$line = $exception->getLine();
|
|
$trace = htmlspecialchars($exception->getTraceAsString());
|
|
|
|
$debugInfo = <<<HTML
|
|
<div class="debug-info">
|
|
<h3>Debug Information</h3>
|
|
<p><strong>Exception:</strong> {$exceptionClass}</p>
|
|
<p><strong>File:</strong> {$file}:{$line}</p>
|
|
<pre>{$trace}</pre>
|
|
</div>
|
|
HTML;
|
|
}
|
|
|
|
return <<<HTML
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>500 - Internal Server Error</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
background: #f5f5f5;
|
|
color: #333;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 100vh;
|
|
}
|
|
.error-container {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
max-width: 800px;
|
|
}
|
|
h1 {
|
|
font-size: 6rem;
|
|
margin: 0;
|
|
color: #e74c3c;
|
|
}
|
|
h2 {
|
|
font-size: 1.5rem;
|
|
margin: 1rem 0;
|
|
font-weight: normal;
|
|
}
|
|
p {
|
|
color: #666;
|
|
margin: 1rem 0;
|
|
}
|
|
.debug-info {
|
|
margin-top: 2rem;
|
|
padding: 1rem;
|
|
background: #fff;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
text-align: left;
|
|
}
|
|
.debug-info h3 {
|
|
margin-top: 0;
|
|
color: #e74c3c;
|
|
}
|
|
.debug-info pre {
|
|
background: #f8f8f8;
|
|
padding: 1rem;
|
|
overflow-x: auto;
|
|
font-size: 0.875rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="error-container">
|
|
<h1>500</h1>
|
|
<h2>{$message}</h2>
|
|
<p>Something went wrong on our end. Please try again later.</p>
|
|
{$debugInfo}
|
|
</div>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
}
|
|
|
|
/**
|
|
* renderErrorPage renders a generic error page
|
|
*/
|
|
private function renderErrorPage(int $status, string $message, ?\Exception $exception): string
|
|
{
|
|
$message = htmlspecialchars($message);
|
|
$debugInfo = '';
|
|
|
|
if ($this->debug && $exception) {
|
|
$exceptionClass = get_class($exception);
|
|
$file = htmlspecialchars($exception->getFile());
|
|
$line = $exception->getLine();
|
|
$trace = htmlspecialchars($exception->getTraceAsString());
|
|
|
|
$debugInfo = <<<HTML
|
|
<div class="debug-info">
|
|
<h3>Debug Information</h3>
|
|
<p><strong>Exception:</strong> {$exceptionClass}</p>
|
|
<p><strong>File:</strong> {$file}:{$line}</p>
|
|
<pre>{$trace}</pre>
|
|
</div>
|
|
HTML;
|
|
}
|
|
|
|
return <<<HTML
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>{$status} - {$message}</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
background: #f5f5f5;
|
|
color: #333;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 100vh;
|
|
}
|
|
.error-container {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
max-width: 800px;
|
|
}
|
|
h1 {
|
|
font-size: 6rem;
|
|
margin: 0;
|
|
color: #e74c3c;
|
|
}
|
|
h2 {
|
|
font-size: 1.5rem;
|
|
margin: 1rem 0;
|
|
font-weight: normal;
|
|
}
|
|
.debug-info {
|
|
margin-top: 2rem;
|
|
padding: 1rem;
|
|
background: #fff;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
text-align: left;
|
|
}
|
|
.debug-info h3 {
|
|
margin-top: 0;
|
|
color: #e74c3c;
|
|
}
|
|
.debug-info pre {
|
|
background: #f8f8f8;
|
|
padding: 1rem;
|
|
overflow-x: auto;
|
|
font-size: 0.875rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="error-container">
|
|
<h1>{$status}</h1>
|
|
<h2>{$message}</h2>
|
|
{$debugInfo}
|
|
</div>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* HttpException base class for HTTP exceptions
|
|
*/
|
|
class HttpException extends \Exception
|
|
{
|
|
protected int $statusCode;
|
|
|
|
public function __construct(int $statusCode, string $message = '', int $code = 0, \Exception $previous = null)
|
|
{
|
|
$this->statusCode = $statusCode;
|
|
parent::__construct($message, $code, $previous);
|
|
}
|
|
|
|
public function getStatusCode(): int
|
|
{
|
|
return $this->statusCode;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ValidationException for validation errors
|
|
*/
|
|
class ValidationException extends HttpException
|
|
{
|
|
private array $errors;
|
|
|
|
public function __construct(array $errors, string $message = 'Validation failed')
|
|
{
|
|
$this->errors = $errors;
|
|
parent::__construct(422, $message);
|
|
}
|
|
|
|
public function getErrors(): array
|
|
{
|
|
return $this->errors;
|
|
}
|
|
}
|