forked from fa/breadcrumb-the-shire
244 lines
7.3 KiB
PHP
244 lines
7.3 KiB
PHP
<?php
|
|
|
|
namespace MintyPHP\Http;
|
|
|
|
use MintyPHP\Debugger;
|
|
|
|
final class ErrorHandler
|
|
{
|
|
/** @phpstan-ignore property.onlyWritten (Memory reserve freed on OOM to allow error rendering.) */
|
|
private static ?string $reservedMemory = null;
|
|
private static bool $registered = false;
|
|
private static bool $handling = false;
|
|
|
|
public static function register(): void
|
|
{
|
|
if (self::$registered) {
|
|
return;
|
|
}
|
|
|
|
self::$registered = true;
|
|
self::$reservedMemory = str_repeat('x', 64 * 1024); // 64 KB reserve for OOM recovery.
|
|
|
|
set_exception_handler([self::class, 'handleException']);
|
|
set_error_handler([self::class, 'handleError']);
|
|
register_shutdown_function([self::class, 'handleShutdown']);
|
|
}
|
|
|
|
public static function handleException(\Throwable $exception): void
|
|
{
|
|
// Prevent recursive handling if the error page itself throws.
|
|
if (self::$handling) {
|
|
self::renderMinimalFallback($exception);
|
|
return;
|
|
}
|
|
|
|
self::$handling = true;
|
|
|
|
try {
|
|
// Free reserved memory so we have room to work after OOM.
|
|
self::$reservedMemory = null;
|
|
|
|
// Discard any partial output from the failed request.
|
|
if (!self::isPhpUnitRuntime()) {
|
|
while (ob_get_level() > 0) {
|
|
ob_end_clean();
|
|
}
|
|
}
|
|
|
|
// Set HTTP 500 if no error status has been set yet.
|
|
$currentStatus = http_response_code();
|
|
if ($currentStatus === false || $currentStatus < 400) {
|
|
http_response_code(500);
|
|
}
|
|
|
|
// Collect diagnostic data.
|
|
$data = ErrorDataCollector::collect($exception);
|
|
|
|
// Log to file (always, in both dev and prod).
|
|
ErrorLogger::log($exception, $data);
|
|
|
|
// Bail out if headers were already sent (cannot render a page).
|
|
if (headers_sent()) {
|
|
self::$handling = false;
|
|
return;
|
|
}
|
|
|
|
// API requests get a JSON response, not HTML.
|
|
if (self::isApiRequest()) {
|
|
self::sendApiError($data['request']['request_id'] ?? null);
|
|
self::$handling = false;
|
|
return;
|
|
}
|
|
|
|
// Render HTML error page.
|
|
if (self::shouldShowDebugPage()) {
|
|
echo ErrorRenderer::renderDev($data);
|
|
} else {
|
|
$requestId = (string) ($data['request']['request_id'] ?? 'unknown');
|
|
echo ErrorRenderer::renderProd($requestId);
|
|
}
|
|
} catch (\Throwable $inner) {
|
|
self::renderMinimalFallback($exception);
|
|
}
|
|
|
|
self::$handling = false;
|
|
}
|
|
|
|
/**
|
|
* Converts PHP errors into ErrorException.
|
|
* Respects the error_reporting() bitmask (honors @ suppression operator).
|
|
*/
|
|
public static function handleError(int $severity, string $message, string $file, int $line): true
|
|
{
|
|
if (!(error_reporting() & $severity)) {
|
|
// Error was suppressed with @.
|
|
return true;
|
|
}
|
|
|
|
if (self::shouldSuppressVendorIssue($severity, $file)) {
|
|
return true;
|
|
}
|
|
|
|
throw new \ErrorException($message, 0, $severity, $file, $line);
|
|
}
|
|
|
|
/**
|
|
* Catches fatal errors that bypass set_error_handler (E_PARSE, E_COMPILE_ERROR, etc.).
|
|
*/
|
|
public static function handleShutdown(): void
|
|
{
|
|
self::$reservedMemory = null;
|
|
|
|
$error = error_get_last();
|
|
if (!is_array($error)) {
|
|
return;
|
|
}
|
|
|
|
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
|
|
if (!in_array($error['type'], $fatalTypes, true)) {
|
|
return;
|
|
}
|
|
|
|
// Only act if we haven't already handled this (e.g. via set_exception_handler).
|
|
if (self::$handling) {
|
|
return;
|
|
}
|
|
|
|
$exception = new \ErrorException(
|
|
$error['message'],
|
|
0,
|
|
$error['type'],
|
|
$error['file'],
|
|
$error['line'],
|
|
);
|
|
|
|
self::handleException($exception);
|
|
}
|
|
|
|
private static function isApiRequest(): bool
|
|
{
|
|
if (defined('MINTY_API_REQUEST')) {
|
|
return true;
|
|
}
|
|
|
|
$uri = $_SERVER['REQUEST_URI'] ?? '';
|
|
$path = ltrim(parse_url($uri, PHP_URL_PATH) ?? '', '/');
|
|
|
|
return str_starts_with($path, 'api/');
|
|
}
|
|
|
|
private static function shouldShowDebugPage(): bool
|
|
{
|
|
// Check the Debugger flag, which mirrors APP_DEBUG.
|
|
if (class_exists(Debugger::class, false) && Debugger::$enabled) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static function sendApiError(?string $requestId): void
|
|
{
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
$payload = [
|
|
'ok' => false,
|
|
'error_code' => 'internal_error',
|
|
];
|
|
if ($requestId !== null) {
|
|
$payload['request_id'] = $requestId;
|
|
}
|
|
|
|
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
|
|
}
|
|
|
|
private static function renderMinimalFallback(\Throwable $exception): void
|
|
{
|
|
// Last-resort plain-text response when everything else is broken.
|
|
$requestId = 'unknown';
|
|
try {
|
|
$requestId = RequestContext::currentId() ?? RequestContext::id();
|
|
} catch (\Throwable) {
|
|
}
|
|
|
|
if (!headers_sent()) {
|
|
http_response_code(500);
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
}
|
|
|
|
echo "500 Internal Server Error\nRequest ID: {$requestId}\n";
|
|
|
|
// In debug mode, also show the exception for developers.
|
|
if (class_exists(Debugger::class, false) && Debugger::$enabled) {
|
|
echo "\n" . $exception->getMessage() . "\n" . $exception->getTraceAsString() . "\n";
|
|
}
|
|
}
|
|
|
|
private static function isPhpUnitRuntime(): bool
|
|
{
|
|
return defined('PHPUNIT_COMPOSER_INSTALL') || defined('__PHPUNIT_PHAR__');
|
|
}
|
|
|
|
private static function shouldSuppressVendorIssue(int $severity, string $file): bool
|
|
{
|
|
if (!self::isVendorPath($file)) {
|
|
return false;
|
|
}
|
|
|
|
return match (self::vendorIssueMode()) {
|
|
'suppress_deprecations' => self::isDeprecationSeverity($severity),
|
|
'suppress_deprecations_warnings' => self::isDeprecationSeverity($severity) || self::isWarningSeverity($severity),
|
|
default => false,
|
|
};
|
|
}
|
|
|
|
private static function vendorIssueMode(): string
|
|
{
|
|
$raw = getenv('APP_VENDOR_PHP_ISSUES_MODE');
|
|
if ($raw === false) {
|
|
return 'strict';
|
|
}
|
|
|
|
$mode = strtolower(trim((string) $raw));
|
|
return in_array($mode, ['strict', 'suppress_deprecations', 'suppress_deprecations_warnings'], true)
|
|
? $mode
|
|
: 'strict';
|
|
}
|
|
|
|
private static function isDeprecationSeverity(int $severity): bool
|
|
{
|
|
return in_array($severity, [E_DEPRECATED, E_USER_DEPRECATED], true);
|
|
}
|
|
|
|
private static function isWarningSeverity(int $severity): bool
|
|
{
|
|
return in_array($severity, [E_WARNING, E_USER_WARNING], true);
|
|
}
|
|
|
|
private static function isVendorPath(string $file): bool
|
|
{
|
|
$normalized = str_replace('\\', '/', $file);
|
|
return str_contains($normalized, '/vendor/');
|
|
}
|
|
}
|