1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2023 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 <https://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\Http\Exceptions\HttpException; 25use Fisharebest\Webtrees\Http\ViewResponseTrait; 26use Fisharebest\Webtrees\Log; 27use Fisharebest\Webtrees\Services\TreeService; 28use Fisharebest\Webtrees\Site; 29use Fisharebest\Webtrees\Validator; 30use League\Flysystem\FilesystemException; 31use Psr\Http\Message\ResponseInterface; 32use Psr\Http\Message\ServerRequestInterface; 33use Psr\Http\Server\MiddlewareInterface; 34use Psr\Http\Server\RequestHandlerInterface; 35use Throwable; 36 37use function app; 38use function dirname; 39use function error_get_last; 40use function ini_get; 41use function ob_end_clean; 42use function ob_get_level; 43use function register_shutdown_function; 44use function response; 45use function str_replace; 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 private TreeService $tree_service; 59 60 /** 61 * HandleExceptions constructor. 62 * 63 * @param TreeService $tree_service 64 */ 65 public function __construct(TreeService $tree_service) 66 { 67 $this->tree_service = $tree_service; 68 } 69 70 /** 71 * @param ServerRequestInterface $request 72 * @param RequestHandlerInterface $handler 73 * 74 * @return ResponseInterface 75 * @throws Throwable 76 */ 77 public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 78 { 79 // Fatal errors. We may be out of memory, so do not create any variables. 80 register_shutdown_function(static function (): void { 81 if (error_get_last() !== null && error_get_last()['type'] & E_ERROR) { 82 // If PHP does not display the error, then we must display it. 83 if (ini_get('display_errors') !== '1') { 84 echo error_get_last()['message'], '<br><br>', error_get_last()['file'], ': ', error_get_last()['line']; 85 } 86 } 87 }); 88 89 try { 90 return $handler->handle($request); 91 } catch (HttpException $exception) { 92 // The router added the tree attribute to the request, and we need it for the error response. 93 if (app()->has(ServerRequestInterface::class)) { 94 $request = app(ServerRequestInterface::class); 95 } else { 96 app()->instance(ServerRequestInterface::class, $request); 97 } 98 99 return $this->httpExceptionResponse($request, $exception); 100 } catch (FilesystemException $exception) { 101 // The router added the tree attribute to the request, and we need it for the error response. 102 $request = app(ServerRequestInterface::class) ?? $request; 103 104 return $this->thirdPartyExceptionResponse($request, $exception); 105 } catch (Throwable $exception) { 106 // Exception thrown while buffering output? 107 while (ob_get_level() > 0) { 108 ob_end_clean(); 109 } 110 111 // The Router middleware may have added a tree attribute to the request. 112 // This might be usable in the error page. 113 if (app()->has(ServerRequestInterface::class)) { 114 $request = app(ServerRequestInterface::class) ?? $request; 115 } 116 117 // Show the exception in a standard webtrees page (if we can). 118 try { 119 return $this->unhandledExceptionResponse($request, $exception); 120 } catch (Throwable) { 121 // That didn't work. Try something else. 122 } 123 124 // Show the exception in a tree-less webtrees page (if we can). 125 try { 126 $request = $request->withAttribute('tree', null); 127 128 return $this->unhandledExceptionResponse($request, $exception); 129 } catch (Throwable) { 130 // That didn't work. Try something else. 131 } 132 133 // Show the exception in an error page (if we can). 134 try { 135 $this->layout = 'layouts/error'; 136 137 return $this->unhandledExceptionResponse($request, $exception); 138 } catch (Throwable) { 139 // That didn't work. Try something else. 140 } 141 142 // Show a stack dump. 143 return response((string) $exception, StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR); 144 } 145 } 146 147 /** 148 * @param ServerRequestInterface $request 149 * @param HttpException $exception 150 * 151 * @return ResponseInterface 152 */ 153 private function httpExceptionResponse(ServerRequestInterface $request, HttpException $exception): ResponseInterface 154 { 155 $tree = Validator::attributes($request)->treeOptional(); 156 $default = Site::getPreference('DEFAULT_GEDCOM'); 157 $tree ??= $this->tree_service->all()[$default] ?? $this->tree_service->all()->first(); 158 159 $status_code = $exception->getCode(); 160 161 // If this was a GET request, then we were probably fetching HTML to display, for 162 // example a chart or tab. 163 if ( 164 $request->getHeaderLine('X-Requested-With') !== '' && 165 $request->getMethod() === RequestMethodInterface::METHOD_GET 166 ) { 167 $this->layout = 'layouts/ajax'; 168 $status_code = StatusCodeInterface::STATUS_OK; 169 } 170 171 return $this->viewResponse('components/alert-danger', [ 172 'alert' => $exception->getMessage(), 173 'title' => $exception->getMessage(), 174 'tree' => $tree, 175 ], $status_code); 176 } 177 178 /** 179 * @param ServerRequestInterface $request 180 * @param Throwable $exception 181 * 182 * @return ResponseInterface 183 */ 184 private function thirdPartyExceptionResponse(ServerRequestInterface $request, Throwable $exception): ResponseInterface 185 { 186 $tree = Validator::attributes($request)->treeOptional(); 187 188 $default = Site::getPreference('DEFAULT_GEDCOM'); 189 $tree ??= $this->tree_service->all()[$default] ?? $this->tree_service->all()->first(); 190 191 if ($request->getHeaderLine('X-Requested-With') !== '') { 192 $this->layout = 'layouts/ajax'; 193 } 194 195 return $this->viewResponse('components/alert-danger', [ 196 'alert' => $exception->getMessage(), 197 'title' => $exception->getMessage(), 198 'tree' => $tree, 199 ], StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR); 200 } 201 202 /** 203 * @param ServerRequestInterface $request 204 * @param Throwable $exception 205 * 206 * @return ResponseInterface 207 */ 208 private function unhandledExceptionResponse(ServerRequestInterface $request, Throwable $exception): ResponseInterface 209 { 210 $this->layout = 'layouts/default'; 211 212 // Create a stack dump for the exception 213 $base_path = dirname(__DIR__, 3); 214 $trace = $exception->getMessage() . ' ' . $exception->getFile() . ':' . $exception->getLine() . PHP_EOL . $exception->getTraceAsString(); 215 $trace = str_replace($base_path, '…', $trace); 216 // User data may contain non UTF-8 characters. 217 $trace = mb_convert_encoding($trace, 'UTF-8', 'UTF-8'); 218 $trace = e($trace); 219 $trace = preg_replace('/^.*modules_v4.*$/m', '<b>$0</b>', $trace); 220 221 try { 222 Log::addErrorLog($trace); 223 } catch (Throwable) { 224 // Must have been a problem with the database. Nothing we can do here. 225 } 226 227 if ($request->getHeaderLine('X-Requested-With') !== '') { 228 // If this was a GET request, then we were probably fetching HTML to display, for 229 // example a chart or tab. 230 if ($request->getMethod() === RequestMethodInterface::METHOD_GET) { 231 $status_code = StatusCodeInterface::STATUS_OK; 232 } else { 233 $status_code = StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR; 234 } 235 236 return response(view('components/alert-danger', ['alert' => $trace]), $status_code); 237 } 238 239 try { 240 // Try with a full header/menu 241 return $this->viewResponse('errors/unhandled-exception', [ 242 'title' => 'Error', 243 'error' => $trace, 244 'request' => $request, 245 'tree' => Validator::attributes($request)->treeOptional(), 246 ], StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR); 247 } catch (Throwable) { 248 // Try with a minimal header/menu 249 return $this->viewResponse('errors/unhandled-exception', [ 250 'title' => 'Error', 251 'error' => $trace, 252 'request' => $request, 253 'tree' => null, 254 ], StatusCodeInterface::STATUS_INTERNAL_SERVER_ERROR); 255 } 256 } 257} 258