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