Issues (105)

src/EventSubscriber/ExceptionSubscriber.php (1 issue)

1
<?php
2
declare(strict_types = 1);
3
/**
4
 * /src/EventSubscriber/ExceptionSubscriber.php
5
 *
6
 * @author TLe, Tarmo Leppänen <[email protected]>
7
 */
8
9
namespace App\EventSubscriber;
10
11
use App\Exception\interfaces\ClientErrorInterface;
12
use App\Security\UserTypeIdentification;
13
use App\Utils\JSON;
14
use Doctrine\DBAL\Exception;
15
use Doctrine\ORM\Exception\ORMException;
16
use JsonException;
17
use Psr\Log\LoggerInterface;
18
use Symfony\Component\DependencyInjection\Attribute\Autowire;
19
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
20
use Symfony\Component\HttpFoundation\Response;
21
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
22
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
23
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
24
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
25
use Symfony\Component\Security\Core\Exception\AuthenticationException;
26
use Throwable;
27
use function array_intersect;
28
use function array_key_exists;
29
use function class_implements;
30
use function in_array;
31
use function spl_object_hash;
32
33
/**
34
 * Class ExceptionSubscriber
35
 *
36
 * @package App\EventSubscriber
37
 * @author TLe, Tarmo Leppänen <[email protected]>
38
 */
39
class ExceptionSubscriber implements EventSubscriberInterface
40
{
41
    /**
42
     * @var array<string, bool>
43
     */
44
    private static array $cache = [];
45
46
    /**
47
     * @var array<int, class-string>
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<int, class-string> at position 4 could not be parsed: Unknown type name 'class-string' at position 4 in array<int, class-string>.
Loading history...
48
     */
49
    private static array $clientExceptions = [
50
        HttpExceptionInterface::class,
51
        ClientErrorInterface::class,
52
    ];
53
54 657
    public function __construct(
55
        private readonly LoggerInterface $logger,
56
        private readonly UserTypeIdentification $userService,
57
        #[Autowire('%kernel.environment%')]
58
        private readonly string $environment,
59
    ) {
60 657
    }
61
62
    /**
63
     * {@inheritdoc}
64
     */
65 1
    public static function getSubscribedEvents(): array
66
    {
67 1
        return [
68 1
            ExceptionEvent::class => [
69 1
                'onKernelException',
70 1
                -100,
71 1
            ],
72 1
        ];
73
    }
74
75
    /**
76
     * Method to handle kernel exception.
77
     *
78
     * @throws JsonException
79
     */
80 422
    public function onKernelException(ExceptionEvent $event): void
81
    {
82
        // Get exception from current event
83 422
        $exception = $event->getThrowable();
84
85
        // Log  error
86 422
        $this->logger->error((string)$exception);
87
88
        // Create new response
89 422
        $response = new Response();
90 422
        $response->headers->set('Content-Type', 'application/json');
91 422
        $response->setStatusCode($this->getStatusCode($exception));
92 422
        $response->setContent(JSON::encode($this->getErrorMessage($exception, $response)));
93
94
        // Send the modified response object to the event
95 422
        $event->setResponse($response);
96
    }
97
98
    /**
99
     * Method to get "proper" status code for exception response.
100
     */
101 437
    private function getStatusCode(Throwable $exception): int
102
    {
103 437
        return $this->determineStatusCode($exception, $this->userService->getSecurityUser() !== null);
104
    }
105
106
    /**
107
     * Method to get actual error message.
108
     *
109
     * @return array<string, mixed>
110
     */
111 422
    private function getErrorMessage(Throwable $exception, Response $response): array
112
    {
113
        // Set base of error message
114 422
        $error = [
115 422
            'message' => $this->getExceptionMessage($exception),
116 422
            'code' => $exception->getCode(),
117 422
            'status' => $response->getStatusCode(),
118 422
        ];
119
120
        // Attach more info to error response in dev environment
121 422
        if ($this->environment === 'dev') {
122 12
            $error += [
123 12
                'debug' => [
124 12
                    'exception' => $exception::class,
125 12
                    'file' => $exception->getFile(),
126 12
                    'line' => $exception->getLine(),
127 12
                    'message' => $exception->getMessage(),
128 12
                    'trace' => $exception->getTrace(),
129 12
                    'traceString' => $exception->getTraceAsString(),
130 12
                ],
131 12
            ];
132
        }
133
134 422
        return $error;
135
    }
136
137
    /**
138
     * Helper method to convert exception message for user. This method is
139
     * used in 'production' environment so, that application won't reveal any
140
     * sensitive error data to users.
141
     */
142 440
    private function getExceptionMessage(Throwable $exception): string
143
    {
144 440
        return $this->environment === 'dev'
145 20
            ? $exception->getMessage()
146 440
            : $this->getMessageForProductionEnvironment($exception);
147
    }
148
149 420
    private function getMessageForProductionEnvironment(Throwable $exception): string
150
    {
151 420
        $message = $exception->getMessage();
152
153 420
        $accessDeniedClasses = [
154 420
            AccessDeniedHttpException::class,
155 420
            AccessDeniedException::class,
156 420
            AuthenticationException::class,
157 420
        ];
158
159 420
        if (in_array($exception::class, $accessDeniedClasses, true)) {
160 96
            $message = 'Access denied.';
161 324
        } elseif ($exception instanceof Exception || $exception instanceof ORMException) {
162
            // Database errors
163 3
            $message = 'Database error.';
164 321
        } elseif (!$this->isClientExceptions($exception)) {
165 12
            $message = 'Internal server error.';
166
        }
167
168 420
        return $message;
169
    }
170
171
    /**
172
     * Method to determine status code for specified exception.
173
     */
174 437
    private function determineStatusCode(Throwable $exception, bool $isUser): int
175
    {
176 437
        $accessDeniedException = static fn (bool $isUser): int => $isUser
177 2
            ? Response::HTTP_FORBIDDEN
178 6
            : Response::HTTP_UNAUTHORIZED;
179
180 437
        $clientException = static fn (HttpExceptionInterface|ClientErrorInterface|Throwable $exception): int =>
181 408
            $exception instanceof HttpExceptionInterface || $exception instanceof ClientErrorInterface
182 408
                ? $exception->getStatusCode()
183 408
                : (int)$exception->getCode();
184
185 437
        $statusCode = match (true) {
186 437
            $exception instanceof AuthenticationException => Response::HTTP_UNAUTHORIZED,
187 437
            $exception instanceof AccessDeniedException => $accessDeniedException($isUser),
188 437
            $this->isClientExceptions($exception) => $clientException($exception),
189 437
            default => 0,
190 437
        };
191
192 437
        return $statusCode > 0 ? $statusCode : Response::HTTP_INTERNAL_SERVER_ERROR;
193
    }
194
195
    /**
196
     * Method to check if exception is ok to show to user (client) or not. Note
197
     * that if this returns true exception message is shown as-is to user.
198
     */
199 433
    private function isClientExceptions(Throwable $exception): bool
200
    {
201 433
        $cacheKey = spl_object_hash($exception);
202
203 433
        if (!array_key_exists($cacheKey, self::$cache)) {
204 430
            $intersect = array_intersect((array)class_implements($exception), self::$clientExceptions);
205
206 430
            self::$cache[$cacheKey] = $intersect !== [];
207
        }
208
209 433
        return self::$cache[$cacheKey];
210
    }
211
}
212