Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve layout for default error handler (redesign HTML and CSS) #37

Merged
merged 8 commits into from
Sep 13, 2021
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# exclude dev files from export to reduce archive download size
/.gitattributes export-ignore
/.github/workflows/ export-ignore
/.gitignore export-ignore
Expand All @@ -7,3 +8,6 @@
/phpunit.xml.dist export-ignore
/phpunit.xml.legacy export-ignore
/tests/ export-ignore

# preserve original EOL with no automatic conversation
* -text
108 changes: 73 additions & 35 deletions src/App.php
Original file line number Diff line number Diff line change
Expand Up @@ -343,13 +343,9 @@ private function routeRequest(ServerRequestInterface $request)
$routeInfo = $this->routeDispatcher->dispatch($request->getMethod(), $request->getUri()->getPath());
switch ($routeInfo[0]) {
case \FastRoute\Dispatcher::NOT_FOUND:
return $this->errorNotFound($request);
return self::errorNotFound($request);
case \FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
$allowedMethods = $routeInfo[1];

return $this->errorMethodNotAllowed(
$request->withAttribute('allowed', $allowedMethods)
);
return $this->errorMethodNotAllowed($routeInfo[1]);
case \FastRoute\Dispatcher::FOUND:
$handler = $routeInfo[1];
$vars = $routeInfo[2];
Expand Down Expand Up @@ -422,76 +418,106 @@ private function log(string $message): void
}
}

private function error(int $statusCode, ?string $info = null): ResponseInterface
private static function error(int $statusCode, string $title, string ...$info): ResponseInterface
{
$response = new Response(
$nonce = \base64_encode(\random_bytes(16));
$info = \implode('', \array_map(function (string $info) { return "<p>$info</p>\n"; }, $info));
$html = <<<HTML
<!DOCTYPE html>
<html>
<head>
<title>Error $statusCode: $title</title>
<style nonce="$nonce">
body { display: grid; justify-content: center; align-items: center; grid-auto-rows: minmax(min-content, calc(100vh - 4em)); margin: 2em; font-family: ui-sans-serif, Arial, "Noto Sans", sans-serif; }
@media (min-width: 700px) { main { display: grid; max-width: 700px; } }
h1 { margin: 0 .5em 0 0; border-right: calc(2 * max(0px, min(100vw - 700px + 1px, 1px))) solid #e3e4e7; padding-right: .5em; color: #aebdcc; font-size: 3em; }
strong { color: #111827; font-size: 3em; }
p { margin: .5em 0 0 0; grid-column: 2; color: #6b7280; }
code { padding: 0 .3em; background-color: #f5f6f9; }
</style>
</head>
<body>
<main>
<h1>$statusCode</h1>
<strong>$title</strong>
$info</main>
</body>
</html>

HTML;

return new Response(
$statusCode,
[
'Content-Type' => 'text/html'
'Content-Type' => 'text/html; charset=utf-8',
'Content-Security-Policy' => "style-src 'nonce-$nonce'; img-src 'self'; default-src 'none'"
],
(string)$statusCode
$html
);

$body = $response->getBody();
$body->seek(0, SEEK_END);

$reason = $response->getReasonPhrase();
if ($reason !== '') {
$body->write(' (' . $reason . ')');
}

if ($info !== null) {
$body->write(': ' . $info);
}
$body->write("\n");

return $response;
}

private function errorProxy(): ResponseInterface
{
return $this->error(
400,
'Proxy requests not allowed'
'Proxy Requests Not Allowed',
'Please check your settings and retry.'
);
}

private function errorNotFound(): ResponseInterface
/**
* @internal
*/
public static function errorNotFound(): ResponseInterface
{
return $this->error(404);
return self::error(
404,
'Page Not Found',
'Please check the URL in the address bar and try again.'
);
}

private function errorMethodNotAllowed(ServerRequestInterface $request): ResponseInterface
private function errorMethodNotAllowed(array $allowedMethods): ResponseInterface
{
$methods = \implode('/', \array_map(function (string $method) { return '<code>' . $method . '</code>'; }, $allowedMethods));

return $this->error(
405,
implode(', ', $request->getAttribute('allowed'))
)->withHeader('Allowed', implode(', ', $request->getAttribute('allowed')));
'Method Not Allowed',
'Please check the URL in the address bar and try again with ' . $methods . ' request.'
)->withHeader('Allow', implode(', ', $allowedMethods));
}

private function errorHandlerException(\Throwable $e): ResponseInterface
{
$where = ' (<code title="See ' . $e->getFile() . ' line ' . $e->getLine() . '">' . \basename($e->getFile()) . ':' . $e->getLine() . '</code>)';
$where = ' in <code title="See ' . $e->getFile() . ' line ' . $e->getLine() . '">' . \basename($e->getFile()) . ':' . $e->getLine() . '</code>';
$message = '<code>' . $this->escapeHtml($e->getMessage()) . '</code>';

return $this->error(
500,
'Expected request handler to return <code>' . ResponseInterface::class . '</code> but got uncaught <code>' . \get_class($e) . '</code>' . $where . ': ' . $e->getMessage()
'Internal Server Error',
'The requested page failed to load, please try again later.',
'Expected request handler to return <code>' . ResponseInterface::class . '</code> but got uncaught <code>' . \get_class($e) . '</code> with message ' . $message . $where . '.'
);
}

private function errorHandlerResponse($value): ResponseInterface
{
return $this->error(
500,
'Expected request handler to return <code>' . ResponseInterface::class . '</code> but got <code>' . $this->describeType($value) . '</code>'
'Internal Server Error',
'The requested page failed to load, please try again later.',
'Expected request handler to return <code>' . ResponseInterface::class . '</code> but got <code>' . $this->describeType($value) . '</code>.'
);
}

private function errorHandlerCoroutine($value): ResponseInterface
{
return $this->error(
500,
'Expected request handler to yield <code>' . PromiseInterface::class . '</code> but got <code>' . $this->describeType($value) . '</code>'
'Internal Server Error',
'The requested page failed to load, please try again later.',
'Expected request handler to yield <code>' . PromiseInterface::class . '</code> but got <code>' . $this->describeType($value) . '</code>.'
);
}

Expand All @@ -504,4 +530,16 @@ private function describeType($value): string
}
return \is_object($value) ? \get_class($value) : \gettype($value);
}

private function escapeHtml(string $s): string
{
return \addcslashes(
\str_replace(
' ',
'&nbsp;',
\htmlspecialchars($s, \ENT_NOQUOTES | \ENT_SUBSTITUTE | \ENT_DISALLOWED, 'utf-8')
),
"\0..\032\\"
);
}
}
8 changes: 1 addition & 7 deletions src/FilesystemHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,13 +124,7 @@ public function __invoke(ServerRequestInterface $request)
\file_get_contents($path)
);
} else {
return new Response(
404,
[
'Content-Type' => 'text/plain; charset=utf-8'
],
"Error 404: Not Found\n"
);
return App::errorNotFound();
}
}

Expand Down
12 changes: 8 additions & 4 deletions tests/AppMiddlewareTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -388,8 +388,10 @@ public function testMiddlewareCallsNextWhichThrowsExceptionReturnsInternalServer
/** @var ResponseInterface $response */
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(500, $response->getStatusCode());
$this->assertEquals('text/html', $response->getHeaderLine('Content-Type'));
$this->assertEquals("500 (Internal Server Error): Expected request handler to return <code>Psr\Http\Message\ResponseInterface</code> but got uncaught <code>RuntimeException</code> (<code title=\"See " . __FILE__ . " line $line\">AppMiddlewareTest.php:$line</code>): Foo\n", (string) $response->getBody());
$this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
$this->assertStringContainsString("<title>Error 500: Internal Server Error</title>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>The requested page failed to load, please try again later.</p>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>Expected request handler to return <code>Psr\Http\Message\ResponseInterface</code> but got uncaught <code>RuntimeException</code> with message <code>Foo</code> in <code title=\"See " . __FILE__ . " line $line\">AppMiddlewareTest.php:$line</code>.</p>\n", (string) $response->getBody());
}

public function testMiddlewareWhichThrowsExceptionReturnsInternalServerErrorResponse()
Expand All @@ -415,8 +417,10 @@ public function testMiddlewareWhichThrowsExceptionReturnsInternalServerErrorResp
/** @var ResponseInterface $response */
$this->assertInstanceOf(ResponseInterface::class, $response);
$this->assertEquals(500, $response->getStatusCode());
$this->assertEquals('text/html', $response->getHeaderLine('Content-Type'));
$this->assertEquals("500 (Internal Server Error): Expected request handler to return <code>Psr\Http\Message\ResponseInterface</code> but got uncaught <code>RuntimeException</code> (<code title=\"See " . __FILE__ . " line $line\">AppMiddlewareTest.php:$line</code>): Foo\n", (string) $response->getBody());
$this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
$this->assertStringContainsString("<title>Error 500: Internal Server Error</title>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>The requested page failed to load, please try again later.</p>\n", (string) $response->getBody());
$this->assertStringContainsString("<p>Expected request handler to return <code>Psr\Http\Message\ResponseInterface</code> but got uncaught <code>RuntimeException</code> with message <code>Foo</code> in <code title=\"See " . __FILE__ . " line $line\">AppMiddlewareTest.php:$line</code>.</p>\n", (string) $response->getBody());
}

public function testGlobalMiddlewareCallsNextReturnsResponseFromController()
Expand Down
Loading