xref: /webtrees/app/Http/Middleware/HandleExceptions.php (revision 2cebb4b438c7c397058c1bb811d1e0ebbdc65e6b)
1<?php
2
3/**
4 * webtrees: online genealogy
5 * Copyright (C) 2019 webtrees development team
6 * This program is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 * You should have received a copy of the GNU General Public License
15 * along with this program. If not, see <http://www.gnu.org/licenses/>.
16 */
17
18declare(strict_types=1);
19
20namespace Fisharebest\Webtrees\Http\Middleware;
21
22use Fig\Http\Message\RequestMethodInterface;
23use Fig\Http\Message\StatusCodeInterface;
24use Fisharebest\Webtrees\Exceptions\HttpException;
25use Fisharebest\Webtrees\Http\ViewResponseTrait;
26use Fisharebest\Webtrees\Log;
27use Fisharebest\Webtrees\Services\TreeService;
28use Fisharebest\Webtrees\Site;
29use League\Flysystem\NotSupportedException;
30use Psr\Http\Message\ResponseInterface;
31use Psr\Http\Message\ServerRequestInterface;
32use Psr\Http\Server\MiddlewareInterface;
33use Psr\Http\Server\RequestHandlerInterface;
34use Throwable;
35
36use function app;
37use function dirname;
38use function error_get_last;
39use function ini_get;
40use function ob_end_clean;
41use function ob_get_level;
42use function register_shutdown_function;
43use function response;
44use function str_replace;
45use function strpos;
46use function view;
47
48use const E_ERROR;
49use const PHP_EOL;
50
51/**
52 * Middleware to handle and render errors.
53 */
54class HandleExceptions implements MiddlewareInterface, StatusCodeInterface
55{
56    use ViewResponseTrait;
57
58    /** @var TreeService */
59    private $tree_service;
60
61    /**
62     * HandleExceptions constructor.
63     *
64     * @param TreeService $tree_service
65     */
66    public function __construct(TreeService $tree_service)
67    {
68        $this->tree_service = $tree_service;
69    }
70
71    /**
72     * @param ServerRequestInterface  $request
73     * @param RequestHandlerInterface $handler
74     *
75     * @return ResponseInterface
76     * @throws Throwable
77     */
78    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
79    {
80        // Fatal errors.  We may be out of memory, so do not create any variables.
81        register_shutdown_function(static function (): void {
82            if (error_get_last() !== null && error_get_last()['type'] & E_ERROR) {
83                // If PHP does not display the error, then we must display it.
84                if (ini_get('display_errors') !== '1') {
85                    echo error_get_last()['message'], '<br><br>', error_get_last()['file'] , ': ', error_get_last()['line'];
86                }
87                // Not our fault?
88                if (strpos(error_get_last()['file'], '/modules_v4/') !== false) {
89                    echo '<br><br>This is an error in a webtrees module.  Upgrade it or disable it.';
90                }
91            }
92        });
93
94        try {
95            return $handler->handle($request);
96        } catch (HttpException $exception) {
97            // The router added the tree attribute to the request, and we need it for the error response.
98            if (app()->has(ServerRequestInterface::class)) {
99                $request = app(ServerRequestInterface::class) ?? $request;
100            }
101
102            return $this->httpExceptionResponse($request, $exception);
103        } catch (NotSupportedException $exception) {
104            // The router added the tree attribute to the request, and we need it for the error response.
105            $request = app(ServerRequestInterface::class) ?? $request;
106
107            return $this->thirdPartyExceptionResponse($request, $exception);
108        } catch (Throwable $exception) {
109            // Exception thrown while buffering output?
110            while (ob_get_level() > 0) {
111                ob_end_clean();
112            }
113
114            // The Router middleware may have added a tree attribute to the request.
115            // This might be usable in the error page.
116            if (app()->has(ServerRequestInterface::class)) {
117                $request = app(ServerRequestInterface::class) ?? $request;
118            }
119
120            // Show the exception in a standard webtrees page (if we can).
121            try {
122                return $this->unhandledExceptionResponse($request, $exception);
123            } catch (Throwable $e) {
124                // That didn't work.  Try something else.
125            }
126
127            // Show the exception in a tree-less webtrees page (if we can).
128            try {
129                $request = $request->withAttribute('tree', null);
130
131                return $this->unhandledExceptionResponse($request, $exception);
132            } catch (Throwable $e) {
133                // That didn't work.  Try something else.
134            }
135
136            // Show the exception in an error page (if we can).
137            try {
138                $this->layout = 'layouts/error';
139
140                return $this->unhandledExceptionResponse($request, $exception);
141            } catch (Throwable $e) {
142                // That didn't work.  Try something else.
143            }
144
145            // Show a stack dump.
146            return response((string) $exception, StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR);
147        }
148    }
149
150    /**
151     * @param ServerRequestInterface $request
152     * @param HttpException          $exception
153     *
154     * @return ResponseInterface
155     */
156    private function httpExceptionResponse(ServerRequestInterface $request, HttpException $exception): ResponseInterface
157    {
158        $tree = $request->getAttribute('tree');
159
160        $default = Site::getPreference('DEFAULT_GEDCOM');
161        $tree = $tree ?? $this->tree_service->all()[$default] ?? $this->tree_service->all()->first();
162
163        $status_code = $exception->getCode();
164
165        // If this was a GET request, then we were probably fetching HTML to display, for
166        // example a chart or tab.
167        if (
168            $request->getHeaderLine('X-Requested-With') !== '' &&
169            $request->getMethod() === RequestMethodInterface::METHOD_GET
170        ) {
171            $this->layout = 'layouts/ajax';
172            $status_code = StatusCodeInterface::STATUS_OK;
173        }
174
175        return $this->viewResponse('components/alert-danger', [
176            'alert' => $exception->getMessage(),
177            'title' => $exception->getMessage(),
178            'tree'  => $tree,
179        ], $status_code);
180    }
181
182    /**
183     * @param ServerRequestInterface $request
184     * @param Throwable              $exception
185     *
186     * @return ResponseInterface
187     */
188    private function thirdPartyExceptionResponse(ServerRequestInterface $request, Throwable $exception): ResponseInterface
189    {
190        $tree = $request->getAttribute('tree');
191
192        $default = Site::getPreference('DEFAULT_GEDCOM');
193        $tree = $tree ?? $this->tree_service->all()[$default] ?? $this->tree_service->all()->first();
194
195        if ($request->getHeaderLine('X-Requested-With') !== '') {
196            $this->layout = 'layouts/ajax';
197        }
198
199        return $this->viewResponse('components/alert-danger', [
200            'alert' => $exception->getMessage(),
201            'title' => $exception->getMessage(),
202            'tree'  => $tree,
203        ], StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR);
204    }
205
206    /**
207     * @param ServerRequestInterface $request
208     * @param Throwable              $exception
209     *
210     * @return ResponseInterface
211     */
212    private function unhandledExceptionResponse(ServerRequestInterface $request, Throwable $exception): ResponseInterface
213    {
214        $this->layout = 'layouts/default';
215
216        // Create a stack dump for the exception
217        $base_path = dirname(__DIR__, 3);
218        $trace     = $exception->getMessage() . ' ' . $exception->getFile() . ':' . $exception->getLine() . PHP_EOL . $exception->getTraceAsString();
219        $trace     = str_replace($base_path, '…', $trace);
220        // User data may contain non UTF-8 characters.
221        $trace     = mb_convert_encoding($trace, 'UTF-8', 'UTF-8');
222        $trace     = e($trace);
223        $trace     = preg_replace('/^.*modules_v4.*$/m', '<b>$0</b>', $trace);
224
225        try {
226            Log::addErrorLog($trace);
227        } catch (Throwable $exception) {
228            // Must have been a problem with the database.  Nothing we can do here.
229        }
230
231        if ($request->getHeaderLine('X-Requested-With') !== '') {
232            // If this was a GET request, then we were probably fetching HTML to display, for
233            // example a chart or tab.
234            if ($request->getMethod() === RequestMethodInterface::METHOD_GET) {
235                $status_code = StatusCodeInterface::STATUS_OK;
236            } else {
237                $status_code = StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR;
238            }
239
240            return response(view('components/alert-danger', ['alert' => $trace]), $status_code);
241        }
242
243        try {
244            // Try with a full header/menu
245            return $this->viewResponse('errors/unhandled-exception', [
246                'title'   => 'Error',
247                'error'   => $trace,
248                'request' => $request,
249                'tree'    => $request->getAttribute('tree'),
250            ], StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR);
251        } catch (Throwable $ex) {
252            // Try with a minimal header/menu
253            return $this->viewResponse('errors/unhandled-exception', [
254                'title'   => 'Error',
255                'error'   => $trace,
256                'request' => $request,
257                'tree'    => null,
258            ], StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR);
259        }
260    }
261}
262