Passed
Pull Request — master (#1701)
by Tarmo
07:59 queued 11s
created

ExceptionSubscriber::getSubscribedEvents()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 0
dl 0
loc 6
ccs 0
cts 0
cp 0
crap 2
rs 10
c 0
b 0
f 0
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\ORMException;
16
use JsonException;
17
use Psr\Log\LoggerInterface;
18
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
19
use Symfony\Component\HttpFoundation\Response;
20
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
21
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
22
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
23
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
24
use Symfony\Component\Security\Core\Exception\AuthenticationException;
25
use Throwable;
26
use function array_intersect;
27
use function array_key_exists;
28
use function class_implements;
29
use function in_array;
30
use function method_exists;
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, string>
48
     */
49
    private static array $clientExceptions = [
50
        HttpExceptionInterface::class,
51
        ClientErrorInterface::class,
52
    ];
53
54
    public function __construct(
55
        private LoggerInterface $logger,
56
        private UserTypeIdentification $userService,
57
        private string $environment,
58
    ) {
59
    }
60
61
    /**
62
     * {@inheritdoc}
63
     *
64
     * @return array<string, array<int, string|int>>
65
     */
66
    public static function getSubscribedEvents(): array
67
    {
68
        return [
69
            ExceptionEvent::class => [
70
                'onKernelException',
71
                -100,
72
            ],
73
        ];
74
    }
75
76
    /**
77
     * Method to handle kernel exception.
78
     *
79
     * @throws JsonException
80
     */
81 422
    public function onKernelException(ExceptionEvent $event): void
82
    {
83
        // Get exception from current event
84 422
        $exception = $event->getThrowable();
85
86
        // Log  error
87 422
        $this->logger->error((string)$exception);
88
89
        // Create new response
90 422
        $response = new Response();
91 422
        $response->headers->set('Content-Type', 'application/json');
92 422
        $response->setStatusCode($this->getStatusCode($exception));
93 422
        $response->setContent(JSON::encode($this->getErrorMessage($exception, $response)));
94
95
        // Send the modified response object to the event
96 422
        $event->setResponse($response);
97
    }
98
99
    /**
100
     * Method to get "proper" status code for exception response.
101
     */
102 437
    private function getStatusCode(Throwable $exception): int
103
    {
104 437
        return $this->determineStatusCode($exception, $this->userService->getSecurityUser() !== null);
105
    }
106
107
    /**
108
     * Method to get actual error message.
109
     *
110
     * @return array<string, mixed>
111
     */
112 422
    private function getErrorMessage(Throwable $exception, Response $response): array
113
    {
114
        // Set base of error message
115 422
        $error = [
116 422
            'message' => $this->getExceptionMessage($exception),
117 422
            'code' => $exception->getCode(),
118 422
            'status' => $response->getStatusCode(),
119
        ];
120
121
        // Attach more info to error response in dev environment
122 422
        if ($this->environment === 'dev') {
123
            $error += [
124
                'debug' => [
125
                    'exception' => $exception::class,
126 12
                    'file' => $exception->getFile(),
127 12
                    'line' => $exception->getLine(),
128 12
                    'message' => $exception->getMessage(),
129 12
                    'trace' => $exception->getTrace(),
130 12
                    'traceString' => $exception->getTraceAsString(),
131
                ],
132
            ];
133
        }
134
135 422
        return $error;
136
    }
137
138
    /**
139
     * Helper method to convert exception message for user. This method is
140
     * used in 'production' environment so, that application won't reveal any
141
     * sensitive error data to users.
142
     */
143 440
    private function getExceptionMessage(Throwable $exception): string
144
    {
145 440
        return $this->environment === 'dev'
146 20
            ? $exception->getMessage()
147 440
            : $this->getMessageForProductionEnvironment($exception);
148
    }
149
150 420
    private function getMessageForProductionEnvironment(Throwable $exception): string
151
    {
152 420
        $message = $exception->getMessage();
153
154 420
        $accessDeniedClasses = [
155
            AccessDeniedHttpException::class,
156
            AccessDeniedException::class,
157
            AuthenticationException::class,
158
        ];
159
160 420
        if (in_array($exception::class, $accessDeniedClasses, true)) {
161 96
            $message = 'Access denied.';
162 324
        } elseif ($exception instanceof Exception || $exception instanceof ORMException) {
163
            // Database errors
164 3
            $message = 'Database error.';
165 321
        } elseif (!$this->isClientExceptions($exception)) {
166 12
            $message = 'Internal server error.';
167
        }
168
169 420
        return $message;
170
    }
171
172
    /**
173
     * Method to determine status code for specified exception.
174
     */
175 437
    private function determineStatusCode(Throwable $exception, bool $isUser): int
176
    {
177
        // Default status code is always 500
178 437
        $statusCode = Response::HTTP_INTERNAL_SERVER_ERROR;
179
180
        // HttpExceptionInterface is a special type of exception that holds status code and header details
181 437
        if ($exception instanceof AuthenticationException) {
182 4
            $statusCode = Response::HTTP_UNAUTHORIZED;
183 433
        } elseif ($exception instanceof AccessDeniedException) {
184 6
            $statusCode = $isUser ? Response::HTTP_FORBIDDEN : Response::HTTP_UNAUTHORIZED;
185 427
        } elseif ($exception instanceof HttpExceptionInterface) {
186 404
            $statusCode = $exception->getStatusCode();
187 23
        } elseif ($this->isClientExceptions($exception)) {
188 4
            $statusCode = (int)$exception->getCode();
189
190 4
            if (method_exists($exception, 'getStatusCode')) {
191 4
                $statusCode = $exception->getStatusCode();
192
            }
193
        }
194
195 437
        return $statusCode === 0 ? Response::HTTP_INTERNAL_SERVER_ERROR : $statusCode;
196
    }
197
198
    /**
199
     * Method to check if exception is ok to show to user (client) or not. Note
200
     * that if this returns true exception message is shown as-is to user.
201
     */
202 334
    private function isClientExceptions(Throwable $exception): bool
203
    {
204 334
        $cacheKey = spl_object_hash($exception);
205
206 334
        if (!array_key_exists($cacheKey, self::$cache)) {
207 333
            $intersect = array_intersect((array)class_implements($exception), self::$clientExceptions);
208
209 333
            self::$cache[$cacheKey] = $intersect !== [];
210
        }
211
212 334
        return self::$cache[$cacheKey];
213
    }
214
}
215