diff --git a/.gitattributes b/.gitattributes index 9709aa5..65a00ff 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ +# exclude dev files from export to reduce archive download size /.gitattributes export-ignore /.github/workflows/ export-ignore /.gitignore export-ignore @@ -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 diff --git a/src/App.php b/src/App.php index 7566337..a07446e 100644 --- a/src/App.php +++ b/src/App.php @@ -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]; @@ -422,60 +418,86 @@ 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 "

$info

\n"; }, $info)); + $html = << + + +Error $statusCode: $title + + + +
+

$statusCode

+$title +$info
+ + + +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 '' . $method . ''; }, $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 = ' (' . \basename($e->getFile()) . ':' . $e->getLine() . ')'; + $where = ' in ' . \basename($e->getFile()) . ':' . $e->getLine() . ''; + $message = '' . $this->escapeHtml($e->getMessage()) . ''; return $this->error( 500, - 'Expected request handler to return ' . ResponseInterface::class . ' but got uncaught ' . \get_class($e) . '' . $where . ': ' . $e->getMessage() + 'Internal Server Error', + 'The requested page failed to load, please try again later.', + 'Expected request handler to return ' . ResponseInterface::class . ' but got uncaught ' . \get_class($e) . ' with message ' . $message . $where . '.' ); } @@ -483,7 +505,9 @@ private function errorHandlerResponse($value): ResponseInterface { return $this->error( 500, - 'Expected request handler to return ' . ResponseInterface::class . ' but got ' . $this->describeType($value) . '' + 'Internal Server Error', + 'The requested page failed to load, please try again later.', + 'Expected request handler to return ' . ResponseInterface::class . ' but got ' . $this->describeType($value) . '.' ); } @@ -491,7 +515,9 @@ private function errorHandlerCoroutine($value): ResponseInterface { return $this->error( 500, - 'Expected request handler to yield ' . PromiseInterface::class . ' but got ' . $this->describeType($value) . '' + 'Internal Server Error', + 'The requested page failed to load, please try again later.', + 'Expected request handler to yield ' . PromiseInterface::class . ' but got ' . $this->describeType($value) . '.' ); } @@ -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( + ' ', + ' ', + \htmlspecialchars($s, \ENT_NOQUOTES | \ENT_SUBSTITUTE | \ENT_DISALLOWED, 'utf-8') + ), + "\0..\032\\" + ); + } } diff --git a/src/FilesystemHandler.php b/src/FilesystemHandler.php index cf48920..1c1372b 100644 --- a/src/FilesystemHandler.php +++ b/src/FilesystemHandler.php @@ -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(); } } diff --git a/tests/AppMiddlewareTest.php b/tests/AppMiddlewareTest.php index 8a6227c..7c7b417 100644 --- a/tests/AppMiddlewareTest.php +++ b/tests/AppMiddlewareTest.php @@ -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 Psr\Http\Message\ResponseInterface but got uncaught RuntimeException (AppMiddlewareTest.php:$line): Foo\n", (string) $response->getBody()); + $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type')); + $this->assertStringContainsString("Error 500: Internal Server Error\n", (string) $response->getBody()); + $this->assertStringContainsString("

The requested page failed to load, please try again later.

\n", (string) $response->getBody()); + $this->assertStringContainsString("

Expected request handler to return Psr\Http\Message\ResponseInterface but got uncaught RuntimeException with message Foo in AppMiddlewareTest.php:$line.

\n", (string) $response->getBody()); } public function testMiddlewareWhichThrowsExceptionReturnsInternalServerErrorResponse() @@ -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 Psr\Http\Message\ResponseInterface but got uncaught RuntimeException (AppMiddlewareTest.php:$line): Foo\n", (string) $response->getBody()); + $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type')); + $this->assertStringContainsString("Error 500: Internal Server Error\n", (string) $response->getBody()); + $this->assertStringContainsString("

The requested page failed to load, please try again later.

\n", (string) $response->getBody()); + $this->assertStringContainsString("

Expected request handler to return Psr\Http\Message\ResponseInterface but got uncaught RuntimeException with message Foo in AppMiddlewareTest.php:$line.

\n", (string) $response->getBody()); } public function testGlobalMiddlewareCallsNextReturnsResponseFromController() diff --git a/tests/AppTest.php b/tests/AppTest.php index 9f0876e..f699184 100644 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -382,8 +382,8 @@ public function testHandleRequestWithProxyRequestReturnsResponseWithMessageThatP /** @var ResponseInterface $response */ $this->assertInstanceOf(ResponseInterface::class, $response); $this->assertEquals(400, $response->getStatusCode()); - $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); - $this->assertEquals("400 (Bad Request): Proxy requests not allowed\n", (string) $response->getBody()); + $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type')); + $this->assertStringContainsString("Error 400: Proxy Requests Not Allowed\n", (string) $response->getBody()); } public function testHandleRequestWithUnknownRouteReturnsResponseWithFileNotFoundMessage() @@ -400,15 +400,41 @@ public function testHandleRequestWithUnknownRouteReturnsResponseWithFileNotFound /** @var ResponseInterface $response */ $this->assertInstanceOf(ResponseInterface::class, $response); $this->assertEquals(404, $response->getStatusCode()); - $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); - $this->assertEquals("404 (Not Found)\n", (string) $response->getBody()); + $this->assertEquals('text/html; charset=utf-8', $response->getHeaderLine('Content-Type')); + $this->assertStringMatchesFormat('style-src \'nonce-%s\'; img-src \'self\'; default-src \'none\'', $response->getHeaderLine('Content-Security-Policy')); + $this->assertStringContainsString("Error 404: Page Not Found\n", (string) $response->getBody()); + $this->assertStringMatchesFormat("%a