diff --git a/.env.example b/.env.example index 11b1785..88e1e25 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ +DEBUG=false + DARK_MODE=false DISPLAY_READMES=true diff --git a/README.md b/README.md index bd96ad2..17a7126 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ A list of changes can be found on the [GitHub Releases](https://github.com/Direc Troubleshooting --------------- +See the [Common Issues](https://github.com/DirectoryLister/DirectoryLister/wiki/Common-Issues) page for a list of common issues and help in solving them. + For general help and support join our [Spectrum Community](https://spectrum.chat/directory-lister) or reach out on [Twitter](https://twitter.com/DirectoryLister). Please report bugs to the [GitHub Issue Tracker](https://github.com/DirectoryLister/DirectoryLister/issues). diff --git a/app/config/app.php b/app/config/app.php index dd48cfd..c82e345 100644 --- a/app/config/app.php +++ b/app/config/app.php @@ -3,6 +3,17 @@ use App\Support\Helpers; return [ + /** + * Enable application debugging and display error messages. + * + * !!! WARNING !!! + * It is recommended that debug remains OFF unless troubleshooting an issue. + * Leaving this enabled WILL cause leakage of sensitive server information. + * + * Default value: false + */ + 'debug' => Helpers::env('DEBUG'), + /** * Enable dark mode? * diff --git a/app/src/Bootstrap/AppManager.php b/app/src/Bootstrap/AppManager.php index feca7e9..785fd46 100644 --- a/app/src/Bootstrap/AppManager.php +++ b/app/src/Bootstrap/AppManager.php @@ -2,11 +2,13 @@ namespace App\Bootstrap; +use App\Exceptions\ExceptionManager; +use App\Middlewares; use App\Providers; use DI\Bridge\Slim\Bridge; use DI\Container; use Invoker\CallableResolver; -use Middlewares; +use Middlewares as HttpMiddlewares; use Slim\App; use Tightenco\Collect\Support\Collection; @@ -17,6 +19,12 @@ class AppManager Providers\ConfigProvider::class, Providers\FinderProvider::class, Providers\TwigProvider::class, + Providers\WhoopsProvider::class, + ]; + + /** @const Array of application middlewares */ + protected const MIDDLEWARES = [ + Middlewares\WhoopsMiddleware::class ]; /** @var Container The applicaiton container */ @@ -26,7 +34,7 @@ class AppManager protected $callableResolver; /** - * Create a new Provider object. + * Create a new AppManager object. * * @param \DI\Container $container * @param \Invoker\CallableResolver $callableResolver @@ -46,10 +54,9 @@ class AppManager { $this->registerProviders(); $app = Bridge::create($this->container); - $app->add(new Middlewares\Expires([ - 'application/zip' => '+1 hour', - 'text/json' => '+1 hour', - ])); + $this->registerMiddlewares($app); + + $this->container->call(ExceptionManager::class); return $app; } @@ -69,4 +76,25 @@ class AppManager } ); } + + /** + * Register application middlewares. + * + * @param \Slim\App $app + * + * @return void + */ + protected function registerMiddlewares(App $app): void + { + Collection::make(self::MIDDLEWARES)->each( + function (string $middleware) use ($app): void { + $app->add($middleware); + } + ); + + $app->add(new HttpMiddlewares\Expires([ + 'application/zip' => '+1 hour', + 'text/json' => '+1 hour', + ])); + } } diff --git a/app/src/Exceptions/ErrorHandler.php b/app/src/Exceptions/ErrorHandler.php new file mode 100644 index 0000000..4c8fd2d --- /dev/null +++ b/app/src/Exceptions/ErrorHandler.php @@ -0,0 +1,63 @@ +view = $view; + } + + /** + * Invoke the ErrorHandler class. + * + * @param \Psr\Http\Message\ServerRequestInterface $request + * @param \Throwable $exception + * @param bool $displayErrorDetails + * @param bool $logErrors + * @param bool $logErrorDetails + * + * @return \Psr\Http\Message\ResponseInterface + */ + public function __invoke( + ServerRequestInterface $request, + Throwable $exception, + bool $displayErrorDetails, + bool $logErrors, + bool $logErrorDetails + ): ResponseInterface { + $response = (new Response)->withStatus(500); + + if (in_array('application/json', explode(',', $request->getHeaderLine('Accept')))) { + $response->getBody()->write(json_encode([ + 'error' => ['message' => self::DEFAULT_ERROR_MESSAGE] + ])); + + return $response->withHeader('Content-Type', 'application/json'); + } + + return $this->view->render($response, 'error.twig', [ + 'message' => self::DEFAULT_ERROR_MESSAGE, + 'subtext' => 'Enable debugging for additional information', + ]); + } +} diff --git a/app/src/Exceptions/ExceptionManager.php b/app/src/Exceptions/ExceptionManager.php new file mode 100644 index 0000000..169623e --- /dev/null +++ b/app/src/Exceptions/ExceptionManager.php @@ -0,0 +1,42 @@ +app = $app; + $this->config = $config; + } + + /** + * Set up and configure exception handling. + * + * @return void + */ + public function __invoke(): void + { + if ($this->config->get('app.debug', false)) { + return; + } + + $errorMiddleware = $this->app->addErrorMiddleware(true, true, true); + $errorMiddleware->setDefaultErrorHandler(ErrorHandler::class); + } +} diff --git a/app/src/Middlewares/WhoopsMiddleware.php b/app/src/Middlewares/WhoopsMiddleware.php new file mode 100644 index 0000000..531a244 --- /dev/null +++ b/app/src/Middlewares/WhoopsMiddleware.php @@ -0,0 +1,72 @@ +whoops = $whoops; + $this->pageHandler = $pageHandler; + $this->jsonHandler = $jsonHandler; + $this->config = $config; + } + + /** + * Invoke the WhoopseMiddleware class. + * + * @param \Psr\Http\Message\ServerRequestInterface $request + * @param \Psr\Http\Server\RequestHandlerInterface $handler + * + * @return \Psr\Http\Message\ResponseInterface + */ + public function __invoke(Request $request, RequestHandler $handler): ResponseInterface + { + $this->pageHandler->addDataTable('Application Config', $this->config->split('app')->toArray()); + $this->pageHandler->setPageTitle( + sprintf('%s • Directory Lister', $this->pageHandler->getPageTitle()) + ); + + $this->whoops->pushHandler($this->pageHandler); + + if (in_array('application/json', explode(',', $request->getHeaderLine('Accept')))) { + $this->whoops->pushHandler($this->jsonHandler); + } + + $this->whoops->register(); + + return $handler->handle($request); + } +} diff --git a/app/src/Providers/WhoopsProvider.php b/app/src/Providers/WhoopsProvider.php new file mode 100644 index 0000000..a612edc --- /dev/null +++ b/app/src/Providers/WhoopsProvider.php @@ -0,0 +1,37 @@ +container = $container; + } + + /** + * Initialize and register the Whoops component. + * + * @return void + */ + public function __invoke(): void + { + $this->container->set(PrettyPageHandler::class, new PrettyPageHandler); + $this->container->set(JsonResponseHandler::class, new JsonResponseHandler); + $this->container->set(RunInterface::class, new Run); + } +} diff --git a/app/views/error.twig b/app/views/error.twig index e17b61c..96051b3 100644 --- a/app/views/error.twig +++ b/app/views/error.twig @@ -4,10 +4,14 @@ {% block content %} {% include 'components/header.twig' %} -
+

{{ message | default('An unexpected error occured') }}

+ +

+ {{ subtext }} +

{% include 'components/footer.twig' %} diff --git a/composer.json b/composer.json index 20ef167..4e8d636 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "php": ">=7.2", "ext-zip": "*", "erusev/parsedown-extra": "^0.8.1", + "filp/whoops": "^2.7", "middlewares/cache": "^2.0", "phlak/config": "^6.1", "php-di/php-di": "^6.0", diff --git a/composer.lock b/composer.lock index f1e6e78..e37f795 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "871b5d48a53bf07bdac340934b41325c", + "content-hash": "1a4f0a5fb5a91dc494cb5479886f6001", "packages": [ { "name": "erusev/parsedown", @@ -151,6 +151,67 @@ ], "time": "2020-02-05T20:36:27+00:00" }, + { + "name": "filp/whoops", + "version": "2.7.1", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "fff6f1e4f36be0e0d0b84d66b413d9dcb0c49130" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/fff6f1e4f36be0e0d0b84d66b413d9dcb0c49130", + "reference": "fff6f1e4f36be0e0d0b84d66b413d9dcb0c49130", + "shasum": "" + }, + "require": { + "php": "^5.5.9 || ^7.0", + "psr/log": "^1.0.1" + }, + "require-dev": { + "mockery/mockery": "^0.9 || ^1.0", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0", + "symfony/var-dumper": "^2.6 || ^3.0 || ^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.6-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "time": "2020-01-15T10:00:00+00:00" + }, { "name": "jeremeamia/superclosure", "version": "2.4.0", @@ -1043,6 +1104,53 @@ ], "time": "2018-10-30T17:12:04+00:00" }, + { + "name": "psr/log", + "version": "1.1.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/446d54b4cb6bf489fc9d75f55843658e6f25d801", + "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "time": "2019-11-01T11:05:21+00:00" + }, { "name": "ralouphie/getallheaders", "version": "3.0.3", @@ -3765,53 +3873,6 @@ ], "time": "2019-01-08T18:20:26+00:00" }, - { - "name": "psr/log", - "version": "1.1.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/446d54b4cb6bf489fc9d75f55843658e6f25d801", - "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801", - "shasum": "" - }, - "require": { - "php": ">=5.3.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "Psr/Log/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "time": "2019-11-01T11:05:21+00:00" - }, { "name": "psy/psysh", "version": "v0.9.12", diff --git a/tests/Bootstrap/AppManangerTest.php b/tests/Bootstrap/AppManangerTest.php index ed1994a..75c8523 100644 --- a/tests/Bootstrap/AppManangerTest.php +++ b/tests/Bootstrap/AppManangerTest.php @@ -3,6 +3,8 @@ namespace Tests\Bootstrap; use App\Bootstrap\AppManager; +use App\Providers; +use DI\Container; use Invoker\CallableResolver; use Slim\App; use Tests\TestCase; @@ -16,4 +18,19 @@ class AppManangerTest extends TestCase $this->assertInstanceOf(App::class, $app); } + + public function test_it_registeres_providers(): void + { + $callableResolver = $this->container->get(CallableResolver::class); + + $container = $this->createMock(Container::class); + $container->expects($this->atLeast(4))->method('call')->withConsecutive( + [$callableResolver->resolve(Providers\ConfigProvider::class)], + [$callableResolver->resolve(Providers\FinderProvider::class)], + [$callableResolver->resolve(Providers\TwigProvider::class)], + [$callableResolver->resolve(Providers\WhoopsProvider::class)], + ); + + (new AppManager($container, $callableResolver))(); + } } diff --git a/tests/Exceptions/ErrorHandlerTest.php b/tests/Exceptions/ErrorHandlerTest.php new file mode 100644 index 0000000..fbc609d --- /dev/null +++ b/tests/Exceptions/ErrorHandlerTest.php @@ -0,0 +1,57 @@ +container->call(TwigProvider::class); + $errorHandler = new ErrorHandler($this->container->get(Twig::class)); + + $response = $errorHandler( + $this->createMock(Request::class), + new Exception('Test exception; please ignore'), + true, + true, + true + ); + + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('text/html', finfo_buffer( + finfo_open(), (string) $response->getBody(), FILEINFO_MIME_TYPE + )); + } + + public function test_it_returns_an_error_for_a_json_request(): void + { + $this->container->call(TwigProvider::class); + $errorHandler = new ErrorHandler($this->container->get(Twig::class)); + + $request = $this->createMock(Request::class); + $request->expects($this->once())->method('getHeaderLine')->willReturn( + 'application/json' + ); + + $response = $errorHandler( + $request, + new Exception('Test exception; please ignore'), + true, + true, + true + ); + + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals('application/json', $response->getHeaderLine('Content-Type')); + $this->assertEquals('An unexpected error occured', json_decode( + (string) $response->getBody() + )->error->message); + } +} diff --git a/tests/Exceptions/ExceptionManagerTest.php b/tests/Exceptions/ExceptionManagerTest.php new file mode 100644 index 0000000..6b8aa6d --- /dev/null +++ b/tests/Exceptions/ExceptionManagerTest.php @@ -0,0 +1,37 @@ +createMock(ErrorMiddleware::class); + $errorMiddleware->expects($this->once()) + ->method('setDefaultErrorHandler') + ->with(ErrorHandler::class); + + $app = $this->createMock(App::class); + $app->expects($this->once()) + ->method('addErrorMiddleware') + ->willReturn($errorMiddleware); + + (new ExceptionManager($app, $this->config))(); + } + + public function test_it_does_not_set_the_default_error_handler_when_debug_is_enabled(): void + { + $this->config->set('app.debug', true); + + $app = $this->createMock(App::class); + $app->expects($this->never())->method('addErrorMiddleware'); + + (new ExceptionManager($app, $this->config))(); + } +} diff --git a/tests/Middlewares/WhoopsMiddlewareTest.php b/tests/Middlewares/WhoopsMiddlewareTest.php new file mode 100644 index 0000000..be8192c --- /dev/null +++ b/tests/Middlewares/WhoopsMiddlewareTest.php @@ -0,0 +1,73 @@ +createMock(PrettyPageHandler::class); + $pageHandler->expects($this->once())->method('getPageTitle')->willReturn( + 'Test title; please ignore' + ); + $pageHandler->expects($this->once())->method('setPageTitle')->with( + 'Test title; please ignore • Directory Lister' + ); + $pageHandler->expects($this->once())->method('addDataTable')->with( + 'Application Config', $this->config->split('app')->toArray() + ); + + $whoops = $this->createMock(RunInterface::class); + $whoops->expects($this->once())->method('pushHandler')->with( + $pageHandler + ); + + $middleware = new WhoopsMiddleware( + $whoops, $pageHandler, new JsonResponseHandler, $this->config + ); + + $middleware( + $this->createMock(ServerRequestInterface::class), + $this->createMock(RequestHandlerInterface::class) + ); + } + + public function test_it_registers_whoops_with_the_json_handler(): void + { + $pageHandler = $this->createMock(PrettyPageHandler::class); + $pageHandler->expects($this->once())->method('getPageTitle')->willReturn( + 'Test title; please ignore' + ); + $pageHandler->expects($this->once())->method('setPageTitle')->with( + 'Test title; please ignore • Directory Lister' + ); + $pageHandler->expects($this->once())->method('addDataTable')->with( + 'Application Config', $this->config->split('app')->toArray() + ); + + $jsonHandler = new JsonResponseHandler; + + $whoops = $this->createMock(RunInterface::class); + $whoops->expects($this->exactly(2))->method('pushHandler')->withConsecutive( + [$pageHandler], + [$jsonHandler] + ); + + $middleware = new WhoopsMiddleware( + $whoops, $pageHandler, $jsonHandler, $this->config + ); + + $request = $this->createMock(ServerRequestInterface::class); + $request->expects($this->once())->method('getHeaderLine')->willReturn('application/json'); + + $middleware($request, $this->createMock(RequestHandlerInterface::class)); + } +}