*/ protected $exceptionMap = []; /** * A list of the internal exception types that should not be reported. * * @var string[] */ protected $internalDontReport = [ HttpException::class, HttpResponseException::class, SuspiciousOperationException::class, TokenMismatchException::class, ]; /** * A list of the inputs that are never flashed for validation exceptions. * * @var string[] */ protected $dontFlash = [ 'current_password', 'password', 'password_confirmation', ]; /** * Create a new exception handler instance. * * @return void */ public function __construct(Application $container) { $this->container = $container; $this->register(); } /** * Register the exception handling callbacks for the application. * * @return void */ public function register() { $this->reportable(function (Throwable $e) { Integration::captureUnhandledException($e); }); } /** * Register a reportable callback. * * @return \Leantime\Core\Exceptions\ReportableHandler */ public function reportable(callable $reportUsing) { if (! $reportUsing instanceof Closure) { $reportUsing = Closure::fromCallable($reportUsing); } return tap(new ReportableHandler($reportUsing), function ($callback) { $this->reportCallbacks[] = $callback; }); } /** * Register a renderable callback. * * @return $this */ public function renderable(callable $renderUsing) { if (! $renderUsing instanceof Closure) { $renderUsing = Closure::fromCallable($renderUsing); } $this->renderCallbacks[] = $renderUsing; return $this; } /** * Register a new exception mapping. * * @param \Closure|string $from * @param \Closure|string|null $to * @return $this * * @throws \InvalidArgumentException */ public function map($from, $to = null) { if (is_string($to)) { $to = function ($exception) use ($to) { return new $to('', 0, $exception); }; } if (is_callable($from) && is_null($to)) { $from = $this->firstClosureParameterType($to = $from); } if (! is_string($from) || ! $to instanceof Closure) { throw new InvalidArgumentException('Invalid exception mapping.'); } $this->exceptionMap[$from] = $to; return $this; } /** * Indicate that the given exception type should not be reported. * * @return $this */ protected function ignore(string $class) { $this->dontReport[] = $class; return $this; } /** * Report or log an exception. * * @return void * * @throws \Throwable */ public function report(Throwable $e) { $e = $this->mapException($e); if ($this->shouldntReport($e)) { return; } if (Reflector::isCallable($reportCallable = [$e, 'report'])) { if ($this->container->call($reportCallable) !== false) { return; } } foreach ($this->reportCallbacks as $reportCallback) { if ($reportCallback->handles($e)) { if ($reportCallback($e) === false) { return; } } } try { $logger = app(LoggerInterface::class); } catch (Exception $ex) { throw $e; // throw the original exception } $logger->error($e->getMessage(), ['exception' => $e]); } /** * Determine if the exception should be reported. * * @return bool */ public function shouldReport(Throwable $e) { return ! $this->shouldntReport($e); } /** * Determine if the exception is in the "do not report" list. * * @return bool */ protected function shouldntReport(Throwable $e) { $dontReport = array_merge($this->dontReport, $this->internalDontReport); return ! is_null(Arr::first($dontReport, function ($type) use ($e) { return $e instanceof $type; })); } /** * Get the default exception context variables for logging. * * @return array */ protected function exceptionContext(Throwable $e) { if (method_exists($e, 'context')) { return $e->context(); } return []; } /** * Get the default context variables for logging. * * @return array */ protected function context() { try { return array_filter([ ]); } catch (Throwable $e) { return []; } } /** * Render an exception into an HTTP response. * * @param \Illuminate\Http\Request $request * @return \Symfony\Component\HttpFoundation\Response * * @throws \Throwable */ public function render($request, Throwable $e) { if (method_exists($e, 'render') && $response = $e->render($request)) { return $response; } elseif ($e instanceof Responsable) { return $e->toResponse($request); } $e = $this->prepareException($this->mapException($e)); foreach ($this->renderCallbacks as $renderCallback) { foreach ($this->firstClosureParameterTypes($renderCallback) as $type) { if (is_a($e, $type)) { $response = $renderCallback($e, $request); if (! is_null($response)) { return $response; } } } } if ($e instanceof HttpResponseException) { return $e->getResponse(); } return $this->shouldReturnJson($request, $e) ? $this->prepareJsonResponse($request, $e) : $this->prepareResponse($request, $e); } /** * Map the exception using a registered mapper if possible. * * @return \Throwable */ protected function mapException(Throwable $e) { foreach ($this->exceptionMap as $class => $mapper) { if (is_a($e, $class)) { return $mapper($e); } } return $e; } /** * Prepare exception for rendering. * * @return \Throwable */ protected function prepareException(Throwable $e) { return $e; } /** * Determine if the exception handler response should be JSON. * * @param \Illuminate\Http\Request $request * @return bool */ protected function shouldReturnJson($request, Throwable $e) { // API requests (x-api-key / bearer) are JSON by contract even when the client omits an // Accept header, so they get a JSON error body instead of an HTML error page. return $request instanceof ApiRequest || $request->expectsJson(); } /** * Prepare a response for the given exception. * * @param \Illuminate\Http\Request $request * @return \Symfony\Component\HttpFoundation\Response */ protected function prepareResponse($request, Throwable $e) { if (! $this->isHttpException($e) && config('debug')) { return $this->toIlluminateResponse($this->convertExceptionToResponse($e), $e); } if (! $this->isHttpException($e)) { $e = new HttpException(500, $e->getMessage()); } return $this->toIlluminateResponse( $this->renderHttpException($e), $e ); } /** * Create a Symfony response for the given exception. * * @return \Symfony\Component\HttpFoundation\Response */ protected function convertExceptionToResponse(Throwable $e) { return new SymfonyResponse( $this->renderExceptionContent($e), $this->isHttpException($e) ? $e->getStatusCode() : 500, $this->isHttpException($e) ? $e->getHeaders() : [] ); } /** * Get the response content for the given exception. * * @return string */ protected function renderExceptionContent(Throwable $e) { try { return config('debug') && class_exists(Whoops::class) ? $this->renderExceptionWithWhoops($e) : $this->renderExceptionWithSymfony($e, config('debug')); } catch (Exception $e) { return $this->renderExceptionWithSymfony($e, config('debug')); } } /** * Render an exception to a string using "Whoops". * * @return string */ protected function renderExceptionWithWhoops(Throwable $e) { return tap(new Whoops, function ($whoops) { $whoops->appendHandler($this->whoopsHandler()); $whoops->writeToOutput(false); $whoops->allowQuit(false); })->handleException($e); } /** * Get the Whoops handler for the application. * * @return \Whoops\Handler\HandlerInterface */ protected function whoopsHandler() { try { return app(HandlerInterface::class); } catch (BindingResolutionException $e) { return (new WhoopsHandler)->forDebug(); } } /** * Render an exception to a string using Symfony. * * @param bool $debug * @return string */ protected function renderExceptionWithSymfony(Throwable $e, $debug) { $renderer = new HtmlErrorRenderer($debug); return $renderer->render($e)->getAsString(); } /** * Render the given HttpException. * * @return \Symfony\Component\HttpFoundation\Response */ protected function renderHttpException(HttpExceptionInterface $e) { try { $view = $this->getHttpExceptionView($e); return app()->make(Template::class)->display($view, 'error', $e->getStatusCode()); } catch (Throwable $e) { return $this->convertExceptionToResponse($e); } } /** * Register the error template hint paths. * * @return void */ protected function registerErrorViewPaths() {} /** * Get the view used to render HTTP exceptions. * * @return string */ protected function getHttpExceptionView(HttpExceptionInterface $e) { $status = $e->getStatusCode(); // Dedicated error pages exist only for these statuses. Anything else (e.g. a 422 from a // ValidationException or a 409 from EntityExistsException — now that typed exceptions // carry real HTTP statuses) falls back to the generic 500 page instead of throwing a // view-not-found that degrades to a raw Symfony error page. return in_array($status, [403, 404, 500, 501], true) ? "errors.error{$status}" : 'errors.error500'; } /** * Map the given exception into an Illuminate response. * * @param \Symfony\Component\HttpFoundation\Response $response * @return \Illuminate\Http\Response */ protected function toIlluminateResponse($response, Throwable $e) { if ($response instanceof SymfonyRedirectResponse) { $response = new RedirectResponse( $response->getTargetUrl(), $response->getStatusCode(), $response->headers->all() ); } else { $response = new Response( $response->getContent(), $response->getStatusCode(), $response->headers->all() ); } return $response->withException($e); } /** * Prepare a JSON response for the given exception. * * @param \Illuminate\Http\Request $request * @return \Illuminate\Http\JsonResponse */ protected function prepareJsonResponse($request, Throwable $e) { return new JsonResponse( $this->convertExceptionToArray($e), $this->isHttpException($e) ? $e->getStatusCode() : 500, $this->isHttpException($e) ? $e->getHeaders() : [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); } /** * Convert the given exception to an array. * * @return array */ protected function convertExceptionToArray(Throwable $e) { return config('debug') ? [ 'message' => $e->getMessage(), 'exception' => get_class($e), 'file' => $e->getFile(), 'line' => $e->getLine(), 'trace' => collect($e->getTrace())->map(function ($trace) { return Arr::except($trace, ['args']); })->all(), ] : [ // Leantime exceptions expose a curated, client-safe message; fall back to the raw // HttpException message (or a generic string) for everything else. Mirrors the // JSON-RPC surface (JsonRpcErrorResponse::fromException), which also uses getClientMessage(). 'message' => $e instanceof LeantimeExceptionInterface ? $e->getClientMessage() : ($this->isHttpException($e) ? $e->getMessage() : 'Server Error'), ]; } /** * Render an exception to the console. * * @param \Symfony\Component\Console\Output\OutputInterface $output * @return void */ public function renderForConsole($output, Throwable $e) { (new ConsoleApplication)->renderThrowable($e, $output); } /** * Determine if the given exception is an HTTP exception. * * @phpstan-assert-if-true \Symfony\Component\HttpKernel\Exception\HttpExceptionInterface $e * * @return bool */ protected function isHttpException(Throwable $e) { return $e instanceof HttpExceptionInterface; } }