Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
24 / 24 |
|
100.00% |
3 / 3 |
CRAP | |
100.00% |
1 / 1 |
| ErrorResponseRenderer | |
100.00% |
23 / 23 |
|
100.00% |
3 / 3 |
19 | |
100.00% |
1 / 1 |
| buildDebugMarkdown | |
100.00% |
17 / 17 |
|
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% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| isHtmxRequest | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Error; |
| 8 | |
| 9 | defined('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 | */ |
| 19 | final 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 • 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 | } |