Root cause: ExceptionHandlingInitializer attempted to autowire EnvironmentType directly, but it was never registered in the DI container. This caused the debug mode resolution to fail silently. Changes: - Use TypedConfiguration instead of EnvironmentType for proper DI - Create ErrorHandlingConfig value object to centralize config - Access debug mode via AppConfig.isDebugEnabled() which respects both APP_DEBUG env var AND EnvironmentType.isDebugEnabled() - Register ErrorHandlingConfig as singleton in container - Remove diagnostic logging from ResponseErrorRenderer This ensures that staging/production environments (where EnvironmentType != DEV) will not display stack traces, code context, or file paths in error responses.
641 lines
21 KiB
PHP
641 lines
21 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\ExceptionHandling\Renderers;
|
|
|
|
use App\Framework\ExceptionHandling\Context\ExceptionContextProvider;
|
|
use App\Framework\ExceptionHandling\ErrorRenderer;
|
|
use App\Framework\ExceptionHandling\Translation\ExceptionMessageTranslator;
|
|
use App\Framework\ExceptionHandling\ValueObjects\StackTrace;
|
|
use App\Framework\Http\Headers;
|
|
use App\Framework\Http\HttpResponse;
|
|
use App\Framework\Http\Status;
|
|
use App\Framework\Meta\MetaData;
|
|
use App\Framework\SyntaxHighlighter\FileHighlighter;
|
|
use App\Framework\View\Engine;
|
|
use App\Framework\View\ProcessingMode;
|
|
use App\Framework\View\RenderContext;
|
|
|
|
/**
|
|
* HTTP Response renderer for API and HTML error pages
|
|
*
|
|
* Uses Template System for HTML rendering and creates JSON responses for API requests.
|
|
* Extracts Response generation logic from ErrorKernel for reuse in middleware recovery patterns.
|
|
*/
|
|
final readonly class ResponseErrorRenderer implements ErrorRenderer
|
|
{
|
|
public function __construct(
|
|
private Engine $engine,
|
|
private bool $isDebugMode = false,
|
|
private ?ExceptionMessageTranslator $messageTranslator = null
|
|
) {}
|
|
|
|
/**
|
|
* Check if this renderer can handle the exception
|
|
*/
|
|
public function canRender(\Throwable $exception): bool
|
|
{
|
|
// Can render any exception in HTTP context
|
|
return PHP_SAPI !== 'cli';
|
|
}
|
|
|
|
/**
|
|
* Render exception to HTTP Response
|
|
*
|
|
* @param \Throwable $exception Exception to render
|
|
* @param ExceptionContextProvider|null $contextProvider Optional context provider
|
|
* @return HttpResponse HTTP Response object
|
|
*/
|
|
public function render(
|
|
\Throwable $exception,
|
|
?ExceptionContextProvider $contextProvider = null
|
|
): HttpResponse {
|
|
// Determine if API or HTML response needed
|
|
$isApiRequest = $this->isApiRequest();
|
|
|
|
if ($isApiRequest) {
|
|
return $this->createApiResponse($exception, $contextProvider);
|
|
}
|
|
|
|
return $this->createHtmlResponse($exception, $contextProvider);
|
|
}
|
|
|
|
/**
|
|
* Create JSON API error response
|
|
*/
|
|
private function createApiResponse(
|
|
\Throwable $exception,
|
|
?ExceptionContextProvider $contextProvider
|
|
): HttpResponse {
|
|
$statusCode = $this->getHttpStatusCode($exception);
|
|
$stackTrace = StackTrace::fromThrowable($exception);
|
|
|
|
// Get user-friendly message if translator is available
|
|
$context = $contextProvider?->get($exception);
|
|
$userMessage = $this->messageTranslator?->translate($exception, $context)
|
|
?? new \App\Framework\ExceptionHandling\Translation\UserFriendlyMessage(
|
|
message: $this->isDebugMode
|
|
? $exception->getMessage()
|
|
: 'An error occurred while processing your request.'
|
|
);
|
|
|
|
$errorData = [
|
|
'error' => [
|
|
'message' => $userMessage->message,
|
|
'type' => $this->isDebugMode ? $this->getShortClassName(get_class($exception)) : 'ServerError',
|
|
'code' => $exception->getCode(),
|
|
]
|
|
];
|
|
|
|
if ($userMessage->title !== null) {
|
|
$errorData['error']['title'] = $userMessage->title;
|
|
}
|
|
if ($userMessage->helpText !== null) {
|
|
$errorData['error']['help'] = $userMessage->helpText;
|
|
}
|
|
|
|
// Add debug information if enabled
|
|
if ($this->isDebugMode) {
|
|
$errorData['error']['file'] = $exception->getFile();
|
|
$errorData['error']['line'] = $exception->getLine();
|
|
$errorData['error']['trace'] = $stackTrace->toArray();
|
|
$errorData['error']['trace_short'] = $stackTrace->formatShort();
|
|
|
|
// Add context from WeakMap if available
|
|
if ($contextProvider !== null) {
|
|
$context = $contextProvider->get($exception);
|
|
if ($context !== null) {
|
|
$errorData['context'] = [
|
|
'operation' => $context->operation,
|
|
'component' => $context->component,
|
|
'request_id' => $context->requestId,
|
|
'occurred_at' => $context->occurredAt?->format('Y-m-d H:i:s'),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
$body = json_encode($errorData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
|
|
|
return new HttpResponse(
|
|
status: Status::from($statusCode),
|
|
body: $body,
|
|
headers: new Headers([
|
|
'Content-Type' => 'application/json',
|
|
'X-Content-Type-Options' => 'nosniff',
|
|
])
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create HTML error page response using Template System
|
|
*/
|
|
private function createHtmlResponse(
|
|
\Throwable $exception,
|
|
?ExceptionContextProvider $contextProvider
|
|
): HttpResponse {
|
|
$statusCode = $this->getHttpStatusCode($exception);
|
|
|
|
// Try to render using template system
|
|
$html = $this->renderWithTemplate($exception, $contextProvider, $statusCode);
|
|
|
|
// Fallback to simple HTML if template rendering fails
|
|
if ($html === null) {
|
|
$html = $this->generateFallbackHtml($exception, $contextProvider, $statusCode);
|
|
}
|
|
|
|
return new HttpResponse(
|
|
status: Status::from($statusCode),
|
|
body: $html,
|
|
headers: new Headers([
|
|
'Content-Type' => 'text/html; charset=utf-8',
|
|
'X-Content-Type-Options' => 'nosniff',
|
|
])
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Render error page using Template System
|
|
*
|
|
* @return string|null Rendered HTML or null if template not found or rendering failed
|
|
*/
|
|
private function renderWithTemplate(
|
|
\Throwable $exception,
|
|
?ExceptionContextProvider $contextProvider,
|
|
int $statusCode
|
|
): ?string {
|
|
try {
|
|
// Determine template name based on status code
|
|
$templateName = $this->getTemplateName($statusCode);
|
|
|
|
// Prepare template data
|
|
$templateData = $this->prepareTemplateData($exception, $contextProvider, $statusCode);
|
|
|
|
// Create RenderContext
|
|
$renderContext = new RenderContext(
|
|
template: $templateName,
|
|
metaData: new MetaData($this->getErrorTitle($statusCode)),
|
|
data: $templateData,
|
|
processingMode: ProcessingMode::FULL
|
|
);
|
|
|
|
// Render template
|
|
return $this->engine->render($renderContext);
|
|
} catch (\Throwable $e) {
|
|
// Template not found or rendering failed - log error and return null for fallback
|
|
error_log(sprintf(
|
|
'Failed to render error template "%s" for status %d: %s in %s:%d',
|
|
$templateName ?? 'unknown',
|
|
$statusCode,
|
|
$e->getMessage(),
|
|
$e->getFile(),
|
|
$e->getLine()
|
|
));
|
|
|
|
// Return null to trigger fallback HTML generation
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get template name based on status code
|
|
*/
|
|
private function getTemplateName(int $statusCode): string
|
|
{
|
|
return match ($statusCode) {
|
|
403 => 'errors/403',
|
|
404 => 'errors/404',
|
|
500 => 'errors/500',
|
|
default => 'errors/error',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Prepare template data for rendering
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function prepareTemplateData(
|
|
\Throwable $exception,
|
|
?ExceptionContextProvider $contextProvider,
|
|
int $statusCode
|
|
): array {
|
|
$data = [
|
|
'statusCode' => $statusCode,
|
|
'title' => $this->getErrorTitle($statusCode),
|
|
'message' => $this->isDebugMode
|
|
? $exception->getMessage()
|
|
: 'An error occurred while processing your request.',
|
|
'exceptionClass' => $this->getShortClassName(get_class($exception)),
|
|
'isDebugMode' => $this->isDebugMode,
|
|
];
|
|
|
|
// Add debug information if enabled
|
|
if ($this->isDebugMode) {
|
|
$stackTrace = StackTrace::fromThrowable($exception);
|
|
$data['debug'] = [
|
|
'file' => $exception->getFile(),
|
|
'line' => $exception->getLine(),
|
|
'trace' => $stackTrace->formatForHtml(),
|
|
'trace_short' => $stackTrace->formatShort(),
|
|
'trace_frames' => $stackTrace->toArray(),
|
|
];
|
|
|
|
// Add context from WeakMap if available
|
|
if ($contextProvider !== null) {
|
|
$context = $contextProvider->get($exception);
|
|
if ($context !== null) {
|
|
$data['context'] = [
|
|
'operation' => $context->operation,
|
|
'component' => $context->component,
|
|
'request_id' => $context->requestId,
|
|
'occurred_at' => $context->occurredAt?->format('Y-m-d H:i:s'),
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Generate fallback HTML error page (when template not found)
|
|
*/
|
|
private function generateFallbackHtml(
|
|
\Throwable $exception,
|
|
?ExceptionContextProvider $contextProvider,
|
|
int $statusCode
|
|
): string {
|
|
// HTML-encode all variables for security
|
|
$title = htmlspecialchars($this->getErrorTitle($statusCode), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$message = htmlspecialchars(
|
|
$this->isDebugMode
|
|
? $exception->getMessage()
|
|
: 'An error occurred while processing your request.',
|
|
ENT_QUOTES | ENT_HTML5,
|
|
'UTF-8'
|
|
);
|
|
|
|
$debugInfo = '';
|
|
if ($this->isDebugMode) {
|
|
$debugInfo = $this->generateDebugSection($exception, $contextProvider);
|
|
}
|
|
|
|
return <<<HTML
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{$title}</title>
|
|
<style>
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
line-height: 1.6;
|
|
color: #333;
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
padding: 2rem;
|
|
background: #f5f5f5;
|
|
}
|
|
.error-container {
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 2rem;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
}
|
|
h1 {
|
|
color: #d32f2f;
|
|
margin-top: 0;
|
|
}
|
|
.error-message {
|
|
background: #fff3cd;
|
|
border-left: 4px solid #ffc107;
|
|
padding: 1rem;
|
|
margin: 1rem 0;
|
|
}
|
|
.debug-info {
|
|
background: #f8f9fa;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: 4px;
|
|
padding: 1rem;
|
|
margin-top: 2rem;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 0.9rem;
|
|
}
|
|
.debug-info pre {
|
|
margin: 0;
|
|
white-space: pre-wrap;
|
|
word-wrap: break-word;
|
|
}
|
|
.context-item {
|
|
margin: 0.5rem 0;
|
|
}
|
|
.context-label {
|
|
font-weight: bold;
|
|
color: #666;
|
|
}
|
|
.stack-trace {
|
|
margin-top: 1rem;
|
|
}
|
|
.stack-trace__header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
.stack-trace__list {
|
|
margin-top: 0.5rem;
|
|
border: 1px solid #e1e3e6;
|
|
border-radius: 6px;
|
|
background: #fff;
|
|
}
|
|
.stack-frame {
|
|
border-bottom: 1px solid #f0f0f0;
|
|
padding: 0.5rem 0.75rem;
|
|
}
|
|
.stack-frame:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.stack-frame__index {
|
|
display: inline-block;
|
|
min-width: 32px;
|
|
color: #666;
|
|
}
|
|
.stack-frame__call {
|
|
font-weight: 600;
|
|
}
|
|
.stack-frame__location {
|
|
color: #666;
|
|
margin-left: 0.5rem;
|
|
}
|
|
.stack-frame__code {
|
|
margin-top: 0.5rem;
|
|
}
|
|
.stack-frame[open] summary {
|
|
font-weight: 600;
|
|
}
|
|
.stack-frame summary {
|
|
list-style: none;
|
|
cursor: pointer;
|
|
}
|
|
.stack-frame summary::-webkit-details-marker { display:none; }
|
|
.stack-frame--vendor {
|
|
opacity: 0.8;
|
|
background: #fafbfc;
|
|
}
|
|
.btn-copy {
|
|
border: 1px solid #d0d4da;
|
|
background: #fff;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
}
|
|
.btn-copy:hover {
|
|
background: #eef1f5;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="error-container">
|
|
<h1>{$title}</h1>
|
|
<div class="error-message">
|
|
<p>{$message}</p>
|
|
</div>
|
|
{$debugInfo}
|
|
</div>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
}
|
|
|
|
/**
|
|
* Generate debug information section
|
|
*/
|
|
private function generateDebugSection(
|
|
\Throwable $exception,
|
|
?ExceptionContextProvider $contextProvider
|
|
): string {
|
|
// HTML-encode all debug information for security
|
|
$exceptionClass = htmlspecialchars($this->getShortClassName(get_class($exception)), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$file = htmlspecialchars($exception->getFile(), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$line = $exception->getLine();
|
|
$stackTrace = StackTrace::fromThrowable($exception);
|
|
$trace = $stackTrace->formatForHtml(); // Already HTML-encoded in formatForHtml
|
|
$traceShort = htmlspecialchars($stackTrace->formatShort(), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$tracePlain = htmlspecialchars($stackTrace->formatForConsole(), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$renderedFrames = $this->renderStackTraceList($stackTrace);
|
|
|
|
$contextHtml = '';
|
|
if ($contextProvider !== null) {
|
|
$context = $contextProvider->get($exception);
|
|
if ($context !== null) {
|
|
$operation = htmlspecialchars($context->operation ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$component = htmlspecialchars($context->component ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$requestId = htmlspecialchars($context->requestId ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$occurredAt = $context->occurredAt?->format('Y-m-d H:i:s') ?? '';
|
|
|
|
$contextHtml = <<<HTML
|
|
<div class="context-item">
|
|
<span class="context-label">Operation:</span> {$operation}
|
|
</div>
|
|
<div class="context-item">
|
|
<span class="context-label">Component:</span> {$component}
|
|
</div>
|
|
<div class="context-item">
|
|
<span class="context-label">Request ID:</span> {$requestId}
|
|
</div>
|
|
<div class="context-item">
|
|
<span class="context-label">Occurred At:</span> {$occurredAt}
|
|
</div>
|
|
HTML;
|
|
}
|
|
}
|
|
|
|
// Code-Ausschnitt für die Exception-Zeile
|
|
$codeSnippet = $this->getCodeSnippet($exception->getFile(), $line);
|
|
|
|
return <<<HTML
|
|
<div class="debug-info">
|
|
<h3>Debug Information</h3>
|
|
<div class="context-item">
|
|
<span class="context-label">Exception:</span> {$exceptionClass}
|
|
</div>
|
|
<div class="context-item">
|
|
<span class="context-label">File:</span> {$file}:{$line}
|
|
</div>
|
|
<div class="context-item">
|
|
<span class="context-label">Short trace:</span> {$traceShort}
|
|
</div>
|
|
{$contextHtml}
|
|
{$codeSnippet}
|
|
<div class="stack-trace">
|
|
<div class="stack-trace__header">
|
|
<h4 style="margin: 0;">Stack Trace</h4>
|
|
<button type="button" class="btn-copy" data-copy-target="full-trace-text">Copy stack</button>
|
|
</div>
|
|
<div class="stack-trace__list">
|
|
{$renderedFrames}
|
|
</div>
|
|
<pre id="full-trace-text" class="stack-trace__raw" aria-label="Stack trace">{$trace}</pre>
|
|
<pre id="full-trace-plain" style="display:none">{$tracePlain}</pre>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
const btn = document.querySelector('[data-copy-target="full-trace-text"]');
|
|
if (!btn) return;
|
|
btn.addEventListener('click', async function () {
|
|
const raw = document.getElementById('full-trace-plain');
|
|
if (!raw) return;
|
|
try {
|
|
await navigator.clipboard.writeText(raw.textContent || '');
|
|
btn.textContent = 'Copied';
|
|
setTimeout(() => btn.textContent = 'Copy stack', 1200);
|
|
} catch (e) {
|
|
btn.textContent = 'Copy failed';
|
|
setTimeout(() => btn.textContent = 'Copy stack', 1200);
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
HTML;
|
|
}
|
|
|
|
/**
|
|
* Determine if current request is API request
|
|
*/
|
|
private function isApiRequest(): bool
|
|
{
|
|
// Check for JSON Accept header
|
|
$acceptHeader = $_SERVER['HTTP_ACCEPT'] ?? '';
|
|
if (str_contains($acceptHeader, 'application/json')) {
|
|
return true;
|
|
}
|
|
|
|
// Check for API path prefix
|
|
$requestUri = $_SERVER['REQUEST_URI'] ?? '';
|
|
if (str_starts_with($requestUri, '/api/')) {
|
|
return true;
|
|
}
|
|
|
|
// Check for AJAX requests
|
|
$requestedWith = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
|
|
if (strtolower($requestedWith) === 'xmlhttprequest') {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get HTTP status code from exception
|
|
*/
|
|
private function getHttpStatusCode(\Throwable $exception): int
|
|
{
|
|
// Map security exceptions to 403 Forbidden
|
|
// Check for specific security exceptions first
|
|
if ($exception instanceof \App\Framework\Exception\Security\CsrfValidationFailedException ||
|
|
$exception instanceof \App\Framework\Exception\Security\HoneypotTriggeredException ||
|
|
$exception instanceof \App\Framework\Exception\SecurityException) {
|
|
return 403;
|
|
}
|
|
|
|
// Use exception code if it's a valid HTTP status code
|
|
$code = $exception->getCode();
|
|
if ($code >= 400 && $code < 600) {
|
|
return $code;
|
|
}
|
|
|
|
// Map common exceptions to status codes
|
|
return match (true) {
|
|
$exception instanceof \InvalidArgumentException => 400,
|
|
$exception instanceof \RuntimeException => 500,
|
|
$exception instanceof \LogicException => 500,
|
|
default => 500,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get user-friendly error title from status code
|
|
*/
|
|
private function getErrorTitle(int $statusCode): string
|
|
{
|
|
return match ($statusCode) {
|
|
400 => 'Bad Request',
|
|
401 => 'Unauthorized',
|
|
403 => 'Forbidden',
|
|
404 => 'Not Found',
|
|
405 => 'Method Not Allowed',
|
|
429 => 'Too Many Requests',
|
|
500 => 'Internal Server Error',
|
|
502 => 'Bad Gateway',
|
|
503 => 'Service Unavailable',
|
|
default => "Error {$statusCode}",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Gibt Klassennamen ohne Namespace zurück
|
|
*/
|
|
private function getShortClassName(string $fullClassName): string
|
|
{
|
|
$parts = explode('\\', $fullClassName);
|
|
return end($parts);
|
|
}
|
|
|
|
/**
|
|
* Gibt Code-Ausschnitt für die Exception-Zeile zurück
|
|
*/
|
|
private function getCodeSnippet(string $file, int $line): string
|
|
{
|
|
if (!file_exists($file)) {
|
|
return '';
|
|
}
|
|
|
|
try {
|
|
$fileHighlighter = new FileHighlighter();
|
|
$startLine = max(0, $line - 5); // 5 Zeilen vor der Exception
|
|
$range = 11; // 5 vor + 1 Exception + 5 nach = 11 Zeilen
|
|
|
|
// FileHighlighter gibt bereits HTML mit Syntax-Highlighting zurück
|
|
$highlightedCode = $fileHighlighter($file, $startLine, $range, $line);
|
|
|
|
return '<h4>Code Context:</h4>' . $highlightedCode;
|
|
} catch (\Throwable $e) {
|
|
// Bei Fehler beim Lesen der Datei, ignoriere Code-Ausschnitt
|
|
return '';
|
|
}
|
|
}
|
|
|
|
private function renderStackTraceList(StackTrace $stackTrace): string
|
|
{
|
|
$frames = [];
|
|
foreach ($stackTrace->getItems() as $index => $item) {
|
|
$call = $item->getCall() !== '' ? $item->getCall() : '{main}';
|
|
$callEscaped = htmlspecialchars($call, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$locationEscaped = htmlspecialchars($item->getShortFile() . ':' . $item->line, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$isVendor = $item->isVendorFrame();
|
|
$expanded = !$isVendor && $index < 3;
|
|
$codeSnippet = '';
|
|
|
|
if (!$isVendor && $index < 3) {
|
|
$codeSnippet = $this->getCodeSnippet($item->file, $item->line);
|
|
}
|
|
|
|
$frames[] = sprintf(
|
|
'<details class="stack-frame%s"%s><summary><span class="stack-frame__index">#%d</span> <span class="stack-frame__call">%s</span> <span class="stack-frame__location">%s</span></summary>%s</details>',
|
|
$isVendor ? ' stack-frame--vendor' : '',
|
|
$expanded ? ' open' : '',
|
|
$index,
|
|
$callEscaped,
|
|
$locationEscaped,
|
|
$codeSnippet !== '' ? '<div class="stack-frame__code">' . $codeSnippet . '</div>' : ''
|
|
);
|
|
}
|
|
|
|
return implode("\n", $frames);
|
|
}
|
|
}
|