ErrorHandler.php

PHP

Path: src/Http/ErrorHandler.php

<?php

namespace mini\Http;

use Throwable;

/**
 * HTTP Error Handler
 *
 * Handles exceptions and renders appropriate error pages using the template system.
 * Templates are resolved in order:
 * 1. Application-specific: _views/errors/{statusCode}.php
 * 2. Framework default: vendor/fubber/mini/_views/errors/{statusCode}.php
 * 3. Debug mode fallback: vendor/fubber/mini/_views/errors/debug.php
 */
class ErrorHandler
{
    /**
     * Render exception as HTML response body
     *
     * @param Throwable $e The exception that was thrown
     * @param int $statusCode HTTP status code (404, 500, etc.)
     * @return string Rendered HTML content
     */
    public static function renderExceptionPage(Throwable $e, int $statusCode): string
    {
        // In debug mode, show detailed exception info
        if (\mini\Mini::$mini->debug) {
            return self::renderDebugPage($e);
        }

        // Try to render status-specific error page (e.g., errors/404.php)
        try {
            return \mini\render("errors/$statusCode.php", [
                'message' => $e->getMessage(),
                'exception' => $e,
            ]);
        } catch (\Exception $renderError) {
            // Template not found or rendering failed, fall back to generic error
            return self::renderFallbackPage($statusCode, $e->getMessage());
        }
    }

    /**
     * Render debug error page with full exception details
     *
     * @param Throwable $e The exception
     * @return string Rendered HTML
     */
    private static function renderDebugPage(Throwable $e): string
    {
        $errorType = get_class($e);
        $shortErrorType = substr($errorType, strrpos($errorType, '\\') + 1);

        try {
            return \mini\render('errors/debug.php', [
                'errorType' => $errorType,
                'shortErrorType' => $shortErrorType,
                'message' => $e->getMessage(),
                'file' => $e->getFile(),
                'line' => $e->getLine(),
                'trace' => $e->getTraceAsString(),
                'exception' => $e,
                'sensitiveValues' => self::collectSensitiveValues(),
            ]);
        } catch (\Exception $renderError) {
            // Even debug template failed, return inline HTML
            return self::renderInlineDebugPage($e, $errorType, $shortErrorType);
        }
    }

    /**
     * Collect sensitive values that should be redacted from error output
     *
     * @return array<string> List of sensitive values to redact
     */
    private static function collectSensitiveValues(): array
    {
        $sensitive = [];
        $sensitivePatterns = ['PASSWORD', 'SECRET', 'KEY', 'TOKEN', 'SALT', 'CREDENTIAL', 'AUTH'];

        // Collect from $_ENV
        foreach ($_ENV as $name => $value) {
            if (!is_string($value) || $value === '') {
                continue;
            }
            foreach ($sensitivePatterns as $pattern) {
                if (stripos($name, $pattern) !== false) {
                    $sensitive[] = $value;
                    break;
                }
            }
        }

        // Also check getenv() for vars not in $_ENV
        foreach ($sensitivePatterns as $pattern) {
            foreach (['DB_PASSWORD', 'DATABASE_PASSWORD', 'MYSQL_PASSWORD', 'APP_KEY', 'APP_SECRET', 'API_KEY', 'API_SECRET', 'JWT_SECRET', 'ENCRYPTION_KEY', 'SALT', 'AUTH_TOKEN'] as $commonName) {
                $value = getenv($commonName);
                if ($value !== false && $value !== '' && !in_array($value, $sensitive, true)) {
                    $sensitive[] = $value;
                }
            }
        }

        // Parse DSN if database is configured
        try {
            $dsn = getenv('DATABASE_URL') ?: getenv('DB_DSN') ?: ($_ENV['DATABASE_URL'] ?? $_ENV['DB_DSN'] ?? null);
            if ($dsn) {
                $parsed = parse_url($dsn);
                if ($parsed) {
                    if (!empty($parsed['pass'])) {
                        $sensitive[] = $parsed['pass'];
                        $sensitive[] = urldecode($parsed['pass']);
                    }
                    if (!empty($parsed['user'])) {
                        $sensitive[] = $parsed['user'];
                    }
                    if (!empty($parsed['host'])) {
                        $sensitive[] = $parsed['host'];
                    }
                    // Database name from path
                    if (!empty($parsed['path'])) {
                        $dbName = ltrim($parsed['path'], '/');
                        if ($dbName) {
                            $sensitive[] = $dbName;
                        }
                    }
                }
            }
        } catch (\Throwable $e) {
            // Ignore DSN parsing errors
        }

        // Remove empty values and duplicates
        return array_values(array_unique(array_filter($sensitive, fn($v) => strlen($v) >= 3)));
    }

    /**
     * Fallback error page when template rendering fails
     *
     * @param int $statusCode HTTP status code
     * @param string $message Error message
     * @return string Rendered HTML
     */
    private static function renderFallbackPage(int $statusCode, string $message): string
    {
        $statusMessages = [
            400 => 'Bad Request',
            401 => 'Unauthorized',
            403 => 'Forbidden',
            404 => 'Not Found',
            500 => 'Internal Server Error',
        ];

        $statusMessage = $statusMessages[$statusCode] ?? 'Error';
        $safeMessage = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');

        return <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>$statusCode - $statusMessage</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 0; padding: 2rem; background: #f5f5f5; }
        .error-container { max-width: 600px; margin: 2rem auto; background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
        h1 { color: #dc3545; margin: 0 0 1rem 0; }
        p { color: #666; line-height: 1.6; }
        .error-code { font-size: 4rem; font-weight: bold; color: #dc3545; margin: 0; }
        .back-link { margin-top: 2rem; }
        .back-link a { color: #007bff; text-decoration: none; }
        .back-link a:hover { text-decoration: underline; }
    </style>
</head>
<body>
    <div class="error-container">
        <div class="error-code">$statusCode</div>
        <h1>$statusMessage</h1>
        <p>$safeMessage</p>
        <div class="back-link">
            <a href="javascript:history.back()">← Go Back</a> |
            <a href="/">Home</a>
        </div>
    </div>
</body>
</html>
HTML;
    }

    /**
     * Inline debug page when template system fails
     *
     * @param Throwable $e The exception
     * @param string $errorType Full exception class name
     * @param string $shortErrorType Short exception class name
     * @return string Rendered HTML
     */
    private static function renderInlineDebugPage(Throwable $e, string $errorType, string $shortErrorType): string
    {
        $message = htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8');
        $file = htmlspecialchars($e->getFile(), ENT_QUOTES, 'UTF-8');
        $line = $e->getLine();
        $trace = htmlspecialchars($e->getTraceAsString(), ENT_QUOTES, 'UTF-8');

        return <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>$errorType (Debug)</title>
    <style>
        body { font-family: 'Consolas', 'Monaco', monospace; margin: 0; padding: 1rem; background: #1e1e1e; color: #d4d4d4; font-size: 14px; line-height: 1.5; }
        .debug-container { max-width: none; background: #2d2d30; padding: 2rem; border-radius: 8px; border: 1px solid #404040; }
        h1 { color: #f44747; margin: 0 0 1rem 0; font-size: 2rem; }
        h2 { color: #569cd6; margin: 2rem 0 1rem 0; font-size: 1.2rem; }
        .error-type { color: #ce9178; font-weight: bold; }
        .error-message { color: #d7ba7d; background: #3c3c3c; padding: 1rem; border-radius: 4px; margin: 1rem 0; }
        .error-location { color: #9cdcfe; }
        .stack-trace { background: #252526; border: 1px solid #404040; border-radius: 4px; padding: 1rem; overflow-x: auto; white-space: pre; color: #cccccc; }
        .debug-info { background: #0e639c20; border: 1px solid #0e639c; border-radius: 4px; padding: 1rem; margin: 1rem 0; }
        .debug-info h3 { color: #569cd6; margin: 0 0 0.5rem 0; }
        .back-link { margin-top: 2rem; }
        .back-link a { color: #569cd6; text-decoration: none; }
        .back-link a:hover { text-decoration: underline; }
    </style>
</head>
<body>
    <div class="debug-container">
        <h1>🐛 $errorType</h1>

        <div class="debug-info">
            <h3>⚠️ Debug Mode Active</h3>
            <p>This detailed error information is only shown because debug mode is enabled. In production, set <code>DEBUG=false</code> in your environment.</p>
        </div>

        <h2>Exception Details</h2>
        <div class="error-type">$errorType</div>
        <div class="error-message">$message</div>
        <div class="error-location"><strong>File:</strong> $file:$line</div>

        <h2>Stack Trace</h2>
        <div class="stack-trace">$trace</div>

        <div class="back-link">
            <a href="javascript:history.back()">← Go Back</a> |
            <a href="/">Home</a>
        </div>
    </div>
</body>
</html>
HTML;
    }
}