Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
ErrorResponseRenderer
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
3 / 3
19
100.00% covered (success)
100.00%
1 / 1
 buildDebugMarkdown
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 respondWithError
n/a
0 / 0
n/a
0 / 0
3
 renderJsonResponse
n/a
0 / 0
n/a
0 / 0
4
 renderHtmxSnippet
n/a
0 / 0
n/a
0 / 0
2
 renderHtmlPage
n/a
0 / 0
n/a
0 / 0
2
 renderProductionHtmlPage
n/a
0 / 0
n/a
0 / 0
2
 isJsonRequest
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isHtmxRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Error;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11/**
12 * Enterprise Error Response Renderer.
13 *
14 * Renders structured 500 responses across different content-negotiation contexts:
15 * REST API JSON payloads, HTMX partial alert snippets, and standalone HTML error pages.
16 *
17 * @package App\Core\Error
18 */
19final class ErrorResponseRenderer
20{
21    private const string CLIPBOARD_WRITE_ATTR =
22        'onclick="navigator.clipboard.writeText(this.nextElementSibling.value);';
23    /**
24     * Builds structured markdown error report for one-click clipboard copying.
25     *
26     * @param string $errorClass Classification or exception class.
27     * @param string $message    Main error message.
28     * @param string $file       Originating file path.
29     * @param int    $line       Originating line number.
30     * @param string $trace      Trace string.
31     * @return string Formatted markdown string.
32     */
33    public static function buildDebugMarkdown(
34        string $errorClass,
35        string $message,
36        string $file,
37        int    $line,
38        string $trace
39    ): string {
40        $method = (string) ($_SERVER['REQUEST_METHOD'] ?? 'GET');
41        $uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
42
43        return sprintf(
44            "### 500 Internal Server Error\n" .
45            "- **Exception:** `%s`\n" .
46            "- **Message:** `%s`\n" .
47            "- **Location:** `%s:%d`\n" .
48            "- **Request:** `%s %s`\n\n" .
49            "```trace\n%s\n```",
50            $errorClass,
51            $message,
52            $file,
53            $line,
54            $method,
55            $uri,
56            $trace !== '' ? $trace : 'No trace available'
57        );
58    }
59
60    /**
61     * Responds with appropriately formatted 500 error payload based on client request type.
62     *
63     * @param string $errorClass Error or exception classification.
64     * @param string $message    Detailed error message.
65     * @param string $file       Filesystem path where error originated.
66     * @param int    $line       Line number where error originated.
67     * @param string $trace      Stack trace string.
68     * @param bool   $isDebug    Whether application is in debug mode.
69     */
70    public static function respondWithError(
71        string $errorClass,
72        string $message,
73        string $file,
74        int    $line,
75        string $trace,
76        bool   $isDebug
77    ): void {
78        // @codeCoverageIgnoreStart
79        http_response_code(500);
80
81        if (self::isJsonRequest()) {
82            self::renderJsonResponse($errorClass, $message, $file, $line, $trace, $isDebug);
83            return;
84        }
85
86        if (self::isHtmxRequest()) {
87            self::renderHtmxSnippet($errorClass, $message, $file, $line, $trace, $isDebug);
88            return;
89        }
90
91        self::renderHtmlPage($errorClass, $message, $file, $line, $trace, $isDebug);
92        // @codeCoverageIgnoreEnd
93    }
94
95    /**
96     * Renders JSON error response for API / AJAX callers.
97     */
98    public static function renderJsonResponse(
99        string $errorClass,
100        string $message,
101        string $file,
102        int    $line,
103        string $trace,
104        bool   $isDebug
105    ): void {
106        // @codeCoverageIgnoreStart
107        header('Content-Type: application/json; charset=UTF-8');
108        $payload = [
109            'status'  => 'error',
110            'code'    => 500,
111            'message' => $isDebug ? $message : 'An unexpected internal error occurred.',
112        ];
113        if ($isDebug) {
114            $payload['exception'] = $errorClass;
115            $payload['file']      = $file;
116            $payload['line']      = $line;
117            $payload['trace']     = $trace !== '' ? explode("\n", $trace) : [];
118        }
119
120        echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
121        // @codeCoverageIgnoreEnd
122    }
123
124    /**
125     * Renders self-contained HTML alert snippet for HTMX callers.
126     */
127    public static function renderHtmxSnippet(
128        string $errorClass,
129        string $message,
130        string $file,
131        int    $line,
132        string $trace,
133        bool   $isDebug
134    ): void {
135        // @codeCoverageIgnoreStart
136        header('Content-Type: text/html; charset=UTF-8');
137        $safeClass = htmlspecialchars($errorClass, ENT_QUOTES, 'UTF-8');
138        $safeMsg = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
139        $safeFile = htmlspecialchars($file, ENT_QUOTES, 'UTF-8');
140        $shortFile = htmlspecialchars(basename($file), ENT_QUOTES, 'UTF-8');
141        $report = self::buildDebugMarkdown($errorClass, $message, $file, $line, $trace);
142        $escapedReport = htmlspecialchars($report, ENT_QUOTES, 'UTF-8');
143
144        if (!$isDebug) {
145            echo '<div class="alert alert-danger p-3 my-2 shadow-sm border border-danger-subtle rounded" ' .
146                'role="alert">' .
147                '<div class="d-flex justify-content-between align-items-center mb-1">' .
148                '<strong>500 Internal Server Error</strong>' .
149                '<button type="button" class="btn btn-sm btn-outline-danger" ' .
150                self::CLIPBOARD_WRITE_ATTR .
151                'this.innerText=\'Copied!\';">' .
152                '📋 Copy Error Info' .
153                '</button>' .
154                '<textarea class="d-none">' . $escapedReport . '</textarea>' .
155                '</div>' .
156                '<div>An unexpected internal error occurred. Please contact the system administrator.</div>' .
157                '<div class="small font-monospace text-secondary mt-2">' .
158                'Error: ' . $safeClass . ' (' . $shortFile . ':' . $line . ')</div>' .
159                '</div>';
160            return;
161        }
162
163        $safeTrace = htmlspecialchars($trace, ENT_QUOTES, 'UTF-8');
164
165        echo '<div class="alert alert-danger p-3 my-2 shadow-sm border border-danger-subtle rounded" role="alert">' .
166            '<div class="d-flex justify-content-between align-items-center mb-2">' .
167            '<span class="badge bg-danger text-white">500 Server Error [DEV]</span>' .
168            '<button type="button" class="btn btn-sm btn-outline-danger" ' .
169            self::CLIPBOARD_WRITE_ATTR .
170            'this.innerText=\'Copied!\';">' .
171            '📋 Copy Error Report' .
172            '</button>' .
173            '<textarea class="d-none">' . $escapedReport . '</textarea>' .
174            '</div>' .
175            '<div class="fw-bold font-monospace text-break mb-1">' . $safeClass . ': ' . $safeMsg . '</div>' .
176            '<div class="small text-secondary font-monospace text-break mb-2">File: ' .
177            $safeFile . ':' . $line . '</div>' .
178            '<details class="small">' .
179            '<summary class="cursor-pointer text-muted">Show Stack Trace</summary>' .
180            '<pre class="bg-dark text-light p-2 rounded mt-1 overflow-auto" ' .
181            'style="max-height:200px;font-size:0.75rem;">' . $safeTrace . '</pre>' .
182            '</details>' .
183            '</div>';
184        // @codeCoverageIgnoreEnd
185    }
186
187    /**
188     * Renders full HTML page for standard browser navigation.
189     */
190    public static function renderHtmlPage(
191        string $errorClass,
192        string $message,
193        string $file,
194        int    $line,
195        string $trace,
196        bool   $isDebug
197    ): void {
198        // @codeCoverageIgnoreStart
199        header('Content-Type: text/html; charset=UTF-8');
200        if (!$isDebug) {
201            self::renderProductionHtmlPage($errorClass, $message, $file, $line, $trace);
202            return;
203        }
204
205        $safeClass = htmlspecialchars($errorClass, ENT_QUOTES, 'UTF-8');
206        $safeMsg = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
207        $safeFile = htmlspecialchars($file, ENT_QUOTES, 'UTF-8');
208        $safeTrace = htmlspecialchars($trace, ENT_QUOTES, 'UTF-8');
209        $reqMethod = htmlspecialchars((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'), ENT_QUOTES, 'UTF-8');
210        $reqUri = htmlspecialchars((string)($_SERVER['REQUEST_URI'] ?? '/'), ENT_QUOTES, 'UTF-8');
211        $report = self::buildDebugMarkdown($errorClass, $message, $file, $line, $trace);
212        $escapedReport = htmlspecialchars($report, ENT_QUOTES, 'UTF-8');
213
214        echo '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">' .
215            '<meta name="viewport" content="width=device-width, initial-scale=1.0">' .
216            '<title>500 Internal Server Error [DEV]</title>' .
217            '<link rel="stylesheet" href="/assets/vendor/tabler/tabler.min.css"></head>' .
218            '<body class="bg-light d-flex flex-column justify-content-center align-items-center min-vh-100 py-4">' .
219            '<main class="container" style="max-width:880px;"><article class="card shadow-lg border-danger">' .
220            '<header class="card-header bg-danger text-white d-flex justify-content-between align-items-center py-3">' .
221            '<h3 class="card-title m-0 fw-bold fs-3 text-white">500 Internal Server Error</h3>' .
222            '<span class="badge bg-white text-danger fw-bold">DEV / DEBUG MODE</span></header>' .
223            '<div class="card-body p-4"><div class="mb-3">' .
224            '<span class="badge bg-danger-subtle text-danger fs-6 fw-bold mb-2">' . $safeClass . '</span>' .
225            '<div class="alert alert-danger font-monospace text-break fs-5 fw-bold p-3 mb-0 user-select-all">' .
226            $safeMsg . '</div></div>' .
227            '<div class="card bg-body-tertiary border-0 mb-3 p-3 font-monospace small">' .
228            '<div class="mb-1 text-break"><strong class="text-secondary">File:</strong> ' .
229            '<span class="text-danger fw-bold user-select-all">' . $safeFile . '</span></div>' .
230            '<div class="mb-1"><strong class="text-secondary">Line:</strong> ' .
231            '<span class="badge bg-dark text-white">' . $line . '</span></div>' .
232            '<div><strong class="text-secondary">Request:</strong> ' .
233            '<span class="badge bg-secondary-subtle text-secondary font-monospace">' .
234            $reqMethod . ' ' . $reqUri . '</span></div></div>' .
235            '<div class="d-flex flex-wrap gap-2 mb-3">' .
236            '<button type="button" class="btn btn-danger" id="btn-copy-report" onclick="copyErrorReport()">' .
237            '📋 Copy Error Report to Clipboard</button>' .
238            '<button type="button" class="btn btn-outline-secondary" onclick="window.location.reload()">' .
239            'Reload Page</button>' .
240            '<a href="/dashboard" class="btn btn-secondary">Return to Dashboard</a></div>' .
241            '<textarea id="raw-error-report" class="d-none">' . $escapedReport . '</textarea>' .
242            '<details class="border rounded p-3 bg-white">' .
243            '<summary class="cursor-pointer text-secondary fw-semibold">' .
244            'Show Full Stack Trace</summary>' .
245            '<pre class="bg-dark text-light p-3 rounded mt-2 mb-0 overflow-auto font-monospace" ' .
246            'style="max-height:320px;font-size:0.8rem;">' . $safeTrace . '</pre></details></div>' .
247            '<footer class="card-footer text-muted small text-center py-2">' .
248            'Ammonly Developer Mode &bull; Error details visible in development mode.</footer>' .
249            '</article></main>' .
250            '<script>' .
251            'function copyErrorReport(){' .
252            'const el=document.getElementById("raw-error-report");' .
253            'const btn=document.getElementById("btn-copy-report");' .
254            'if(!el)return;' .
255            'if(navigator.clipboard&&navigator.clipboard.writeText){' .
256            'navigator.clipboard.writeText(el.value).then(()=>' .
257            '{if(btn)btn.innerText="✅ Error report copied to clipboard!";});' .
258            '}else{' .
259            'el.classList.remove("d-none");el.select();document.execCommand("copy");el.classList.add("d-none");' .
260            'if(btn)btn.innerText="✅ Error report copied to clipboard!";' .
261            '}}' .
262            '</script></body></html>';
263        // @codeCoverageIgnoreEnd
264    }
265
266    /**
267     * Renders safe 500 error page for production environments.
268     */
269    public static function renderProductionHtmlPage(
270        string $errorClass = '',
271        string $message = '',
272        string $file = '',
273        int    $line = 0,
274        string $trace = ''
275    ): void {
276        // @codeCoverageIgnoreStart
277        $safeClass = htmlspecialchars($errorClass, ENT_QUOTES, 'UTF-8');
278        $shortFile = htmlspecialchars(basename($file), ENT_QUOTES, 'UTF-8');
279        $report = self::buildDebugMarkdown($errorClass, $message, $file, $line, $trace);
280        $escapedReport = htmlspecialchars($report, ENT_QUOTES, 'UTF-8');
281
282        echo '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">' .
283            '<meta name="viewport" content="width=device-width, initial-scale=1.0">' .
284            '<title>500 Internal Server Error</title>' .
285            '<link rel="stylesheet" href="/assets/vendor/tabler/tabler.min.css"></head>' .
286            '<body class="d-flex flex-column justify-content-center align-items-center vh-100 bg-light p-3">' .
287            '<div class="card p-4 shadow-sm text-center" style="max-width: 560px;">' .
288            '<h3 class="text-danger mb-2">500 Internal Server Error</h3>' .
289            '<p class="text-muted mb-3">' .
290            'An unexpected internal error occurred. Please contact system administrator.</p>' .
291            ($shortFile !== '' ? (
292                '<div class="alert alert-secondary font-monospace small text-start p-3 mb-3">' .
293                '<div class="mb-1"><strong>Error:</strong> ' . $safeClass . '</div>' .
294                '<div class="mb-2"><strong>Location:</strong> ' . $shortFile . ':' . $line . '</div>' .
295                '<button type="button" class="btn btn-sm btn-outline-secondary w-100" ' .
296                self::CLIPBOARD_WRITE_ATTR .
297                'this.innerText=\'Error report copied to clipboard!\';">' .
298                '📋 Copy Error Details</button>' .
299                '<textarea class="d-none">' . $escapedReport . '</textarea>' .
300                '</div>'
301            ) : '') .
302            '<a href="/dashboard" class="btn btn-primary">Return to Dashboard</a></div></body></html>';
303        // @codeCoverageIgnoreEnd
304    }
305
306    /**
307     * Checks if current request expects a JSON response.
308     */
309    public static function isJsonRequest(): bool
310    {
311        $accept = (string)($_SERVER['HTTP_ACCEPT'] ?? '');
312        if (str_contains($accept, 'application/json')) {
313            return true;
314        }
315
316        $xhr = strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? ''));
317
318        return $xhr === 'xmlhttprequest' && !isset($_SERVER['HTTP_HX_REQUEST']);
319    }
320
321    /**
322     * Checks if current request is originated by HTMX.
323     */
324    public static function isHtmxRequest(): bool
325    {
326        return isset($_SERVER['HTTP_HX_REQUEST']);
327    }
328}