app/Service/EventSubscriber/KernelErrorSubscriber.php line 94

Open in your IDE?
  1. <?php
  2. namespace Sq\Service\EventSubscriber;
  3. use GraphQL\Server\RequestError as GraphQLRequestError;
  4. use Laminas\Diactoros\Exception\InvalidArgumentException as LaminasInvalidArgumentException;
  5. use Psr\Container\ContainerInterface;
  6. use Psr\Log\LoggerInterface;
  7. use Psr\Log\LogLevel;
  8. use Sq\Event\Kernel\InitialiseContainerEvent;
  9. use Sq\Exception\Controller\AdminAccessDeniedException;
  10. use Sq\Exception\Controller\MemberAccessDeniedException;
  11. use Sq\Exception\Controller\MFARequiredException;
  12. use Sq\Exception\Controller\MFAVerificationException;
  13. use Sq\Service\Environment;
  14. use Sq\Service\FeatureChecker;
  15. use Sq\Service\Kernel;
  16. use Sq\Service\ReactApp;
  17. use Symfony\Component\EventDispatcher\EventDispatcherInterface;
  18. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  19. use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
  20. use Symfony\Component\HttpFoundation\JsonResponse;
  21. use Symfony\Component\HttpFoundation\RedirectResponse;
  22. use Symfony\Component\HttpFoundation\Request;
  23. use Symfony\Component\HttpFoundation\Response;
  24. use Symfony\Component\HttpKernel\Event\ExceptionEvent;
  25. use Symfony\Component\HttpKernel\EventListener\ErrorListener as SymfonyErrorListener;
  26. use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
  27. use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
  28. use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
  29. use Symfony\Component\HttpKernel\KernelEvents;
  30. use Symfony\Component\Routing\Exception\InvalidParameterException;
  31. use Symfony\Component\Routing\Exception\MethodNotAllowedException;
  32. use Symfony\Component\Routing\Exception\ResourceNotFoundException;
  33. use TheCodingMachine\GraphQLite\Mappers\CannotMapTypeException;
  34. class KernelErrorSubscriber implements EventSubscriberInterface
  35. {
  36.     /** @var Environment */
  37.     protected $environment;
  38.     /** @var EventDispatcherInterface */
  39.     protected $dispatcher;
  40.     /** @var LoggerInterface */
  41.     protected $logger;
  42.     /** @var FeatureChecker */
  43.     protected $featureChecker;
  44.     /** @var ContainerInterface */
  45.     protected $container;
  46.     /** @var ReactApp */
  47.     private $reactApp;
  48.     /** @var string[] */
  49.     protected $exceptionLogLevels = [
  50.         SuspiciousOperationException::class => LogLevel::ERROR,
  51.         MethodNotAllowedException::class => LogLevel::ERROR,
  52.         InvalidParameterException::class => LogLevel::ERROR,
  53.         BadRequestHttpException::class => LogLevel::ERROR,
  54.         ResourceNotFoundException::class => LogLevel::ERROR,
  55.     ];
  56.     public function __construct(
  57.         Environment $environment,
  58.         EventDispatcherInterface $dispatcher,
  59.         LoggerInterface $logger,
  60.         FeatureChecker $featureChecker,
  61.         ContainerInterface $container,
  62.         ReactApp $reactApp
  63.     ) {
  64.         $this->environment = $environment;
  65.         $this->dispatcher = $dispatcher;
  66.         $this->logger = $logger;
  67.         $this->featureChecker = $featureChecker;
  68.         $this->container = $container;
  69.         $this->reactApp = $reactApp;
  70.     }
  71.     public static function getSubscribedEvents()
  72.     {
  73.         return [
  74.             KernelEvents::EXCEPTION => ['onKernelException'],
  75.             InitialiseContainerEvent::class => ['onKernelInitialiseContainer']
  76.         ];
  77.     }
  78.     /**
  79.      * Removes the logKernelException listener as we do our own logging.
  80.      * For Symfony 5.x we will need to update this to remove the SymfonyExceptionListener.
  81.      */
  82.     public function onKernelInitialiseContainer()
  83.     {
  84.         $dispatcher = $this->dispatcher;
  85.         $listeners = $dispatcher->getListeners("kernel.exception");
  86.         foreach ($listeners as $listener)
  87.         {
  88.             if (is_array($listener)
  89.                 && ($listener[0] instanceof SymfonyErrorListener)
  90.                 && $listener[1] == "logKernelException"
  91.             ) {
  92.                 $dispatcher->removeListener("kernel.exception", $listener);
  93.                 break;
  94.             }
  95.         }
  96.     }
  97.     /**
  98.      * Handles kernel exceptions we want to (e.g. 404, 500, access denied).
  99.      *
  100.      * @param ExceptionEvent $event
  101.      *
  102.      * @throws \Exception
  103.      */
  104.     public function onKernelException(ExceptionEvent $event)
  105.     {
  106.         // You get the exception object from the received event
  107.         $throwable = $event->getThrowable();
  108.         $response = null;
  109.         switch (get_class($throwable))
  110.         {
  111.             case MFARequiredException::class:
  112.                 $response = $this->handleMFARequired($event->getRequest());
  113.                 break;
  114.             case MFAVerificationException::class:
  115.                 $response = $this->handleMFAVerification($event->getRequest());
  116.                 break;
  117.             case AdminAccessDeniedException::class:
  118.                 $response = $this->handleAdminAccessDenied($event->getRequest());
  119.                 break;
  120.             case MemberAccessDeniedException::class:
  121.                 $response = $this->handleMemberAccessDenied($event->getRequest());
  122.                 break;
  123.             case NotFoundHttpException::class:
  124.                 if ($this->reactApp->shouldServeReactForNotFound())
  125.                 {
  126.                     try
  127.                     {
  128.                         $response = $this->reactApp->renderReactApp();
  129.                         $event->allowCustomResponseCode();
  130.                     }
  131.                     catch (\Throwable $e)
  132.                     {
  133.                         $event->setThrowable($e);
  134.                     }
  135.                 }
  136.                 break;
  137.         }
  138.         $this->logError($throwable, $event->getRequest());
  139.         // Send the modified response object to the event
  140.         if ($response instanceof Response)
  141.         {
  142.             $event->setResponse($response);
  143.         }
  144.     }
  145.     /**
  146.      * Logs the error to the appropriate level.
  147.      *
  148.      * @param \Throwable $throwable
  149.      */
  150.     protected function logError(\Throwable $throwable, ?Request $request = null)
  151.     {
  152.         $logLevel = LogLevel::CRITICAL;
  153.         if ($throwable instanceof NotFoundHttpException || $throwable instanceof ResourceNotFoundException)
  154.         {
  155.             return;
  156.         }
  157.         if (($throwable instanceof HttpExceptionInterface && $throwable->getStatusCode() < 500) ||
  158.             $throwable instanceof MFARequiredException ||
  159.             $throwable instanceof MFAVerificationException ||
  160.             $throwable instanceof AdminAccessDeniedException ||
  161.             $throwable instanceof MemberAccessDeniedException ||
  162.             $throwable instanceof \RuntimeException && str_contains($throwable->getMessage(), 'Invalid JSON received in POST body') ||
  163.             $throwable instanceof GraphQLRequestError && str_contains($throwable->getMessage(), 'HTTP Method') && str_contains($throwable->getMessage(), 'is not supported') ||
  164.             $throwable instanceof LaminasInvalidArgumentException ||
  165.             $this->isUnknownGraphQLTypeLookupException($throwable, $request) ||
  166.             $throwable instanceof \PDOException && str_contains($throwable->getMessage(), 'Illegal mix of collations')
  167.         ) {
  168.             $logLevel = LogLevel::ERROR;
  169.         }
  170.         if (array_key_exists(get_class($throwable), $this->exceptionLogLevels))
  171.         {
  172.             $logLevel = $this->exceptionLogLevels[get_class($throwable)];
  173.         }
  174.         $this->logger->log($logLevel, "{exception}", ['exception' => $throwable]);
  175.     }
  176.     /**
  177.      * Whether this is GraphQLite's request-originated unknown named type lookup failure.
  178.      *
  179.      * @param \Throwable $throwable
  180.      * @param Request|null $request
  181.      *
  182.      * @return bool
  183.      */
  184.     private function isUnknownGraphQLTypeLookupException(\Throwable $throwable, ?Request $request): bool
  185.     {
  186.         return $request !== null &&
  187.             $request->attributes->get('_route') === Kernel::GRAPHQL_ROUTE_NAME &&
  188.             $throwable instanceof CannotMapTypeException &&
  189.             preg_match('/^cannot find GraphQL type "[^"\r\n]*"\. Check your TypeMapper configuration\.$/', $throwable->getMessage()) === 1;
  190.     }
  191.     /**
  192.      * Handle MFA required for this page.
  193.      * Public method, as it's used in admin_only.inc.php for legacy pages.
  194.      *
  195.      * @param Request $request
  196.      *
  197.      * @return Response|null
  198.      */
  199.     public function handleMFARequired(Request $request)
  200.     {
  201.         if ($request->isXmlHttpRequest())
  202.         {
  203.             return new JsonResponse(["status" => "ERROR", "msg" => "MFA is required for this page"], 401);
  204.         }
  205.         return new RedirectResponse("/mfa/required?uri=" . urlencode($request->getRequestUri()));
  206.     }
  207.     /**
  208.      * Handle MFA verification required for this page.
  209.      * Public method, as it's used in admin_only.inc.php for legacy pages.
  210.      *
  211.      * @param Request $request
  212.      *
  213.      * @return Response|null
  214.      */
  215.     public function handleMFAVerification(Request $request)
  216.     {
  217.         if ($request->isXmlHttpRequest())
  218.         {
  219.             return new JsonResponse(["status" => "ERROR", "msg" => "MFA verification is required for this page"], 401);
  220.         }
  221.         return new RedirectResponse("/mfa/verify?r=" . urlencode($request->getRequestUri()));
  222.     }
  223.     /**
  224.      * Handle denied attempt to a member page.
  225.      * Public method, as it's used in admin_only.inc.php for legacy pages.
  226.      *
  227.      * @param Request $request
  228.      *
  229.      * @return Response|null
  230.      */
  231.     public function handleAdminAccessDenied(Request $request)
  232.     {
  233.         if ($request->isXmlHttpRequest())
  234.         {
  235.             return new JsonResponse(["status" => "ERROR", "msg" => "You must be logged in as an administrator"], 401);
  236.         }
  237.         return new RedirectResponse($request->getSchemeAndHttpHost());
  238.     }
  239.     /**
  240.      * Handle denied attempt to a member page.
  241.      * Public method, as it's used in members_only.inc.php for legacy pages.
  242.      *
  243.      * @param Request $request
  244.      *
  245.      * @return Response|null
  246.      */
  247.     public function handleMemberAccessDenied(Request $request)
  248.     {
  249.         if ($request->isXmlHttpRequest())
  250.         {
  251.             return new JsonResponse(["status" => "ERROR", "msg" => "You are no longer logged in"], 401);
  252.         }
  253.         return new RedirectResponse($request->getSchemeAndHttpHost() . "/login?r=" . urlencode(ltrim($request->getRequestUri(), "/")));
  254.     }
  255. }