244 lines
7.4 KiB
PHP
244 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace App\Exceptions;
|
|
|
|
use App\Support\ApiError;
|
|
use Illuminate\Database\Connectors\ConnectionException as DatabaseConnectionException;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
|
use Illuminate\Http\Client\ConnectionException as HttpClientConnectionException;
|
|
use Illuminate\Queue\InvalidQueueException;
|
|
use Illuminate\Queue\MaxAttemptsExceededException;
|
|
use Illuminate\Routing\Exceptions\InvalidSignatureException;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Validation\ValidationException;
|
|
use League\Flysystem\FilesystemException;
|
|
use PDOException;
|
|
use Sentry\State\Scope;
|
|
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
|
use Throwable;
|
|
|
|
class Handler extends ExceptionHandler
|
|
{
|
|
/**
|
|
* The list of the inputs that are never flashed to the session on validation exceptions.
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
protected $dontFlash = [
|
|
'current_password',
|
|
'password',
|
|
'password_confirmation',
|
|
];
|
|
|
|
/**
|
|
* Register the exception handling callbacks for the application.
|
|
*/
|
|
public function register(): void
|
|
{
|
|
$this->reportable(function (Throwable $e) {
|
|
if ($this->shouldSkipSentry($e)) {
|
|
return;
|
|
}
|
|
|
|
if (! app()->bound('sentry') || empty(config('sentry.dsn'))) {
|
|
return;
|
|
}
|
|
|
|
$this->configureSentryScope();
|
|
|
|
app('sentry')->captureException($e);
|
|
});
|
|
}
|
|
|
|
public function render($request, Throwable $e)
|
|
{
|
|
if ($request->expectsJson()) {
|
|
if ($e instanceof ValidationException) {
|
|
return ApiError::response(
|
|
'validation_failed',
|
|
'Validation failed',
|
|
'The given data was invalid.',
|
|
422,
|
|
['errors' => $e->errors()],
|
|
);
|
|
}
|
|
|
|
$status = $this->isHttpException($e)
|
|
? $this->toHttpException($e)->getStatusCode()
|
|
: 500;
|
|
|
|
$code = $status >= 500 ? 'server_error' : 'request_failed';
|
|
|
|
return ApiError::response(
|
|
$code,
|
|
$status >= 500 ? 'Unexpected error' : 'Request could not be completed',
|
|
$this->buildGenericMessage($status),
|
|
$status,
|
|
);
|
|
}
|
|
|
|
if ($request->inertia()) {
|
|
if ($e instanceof ValidationException) {
|
|
return back()->withErrors($e->errors())->withInput($request->all());
|
|
}
|
|
}
|
|
|
|
if ($e instanceof InvalidSignatureException && ! $request->expectsJson()) {
|
|
$request->session()->flash('verification', [
|
|
'status' => 'error',
|
|
'title' => __('auth.verification.expired_title'),
|
|
'message' => __('auth.verification.expired_message'),
|
|
]);
|
|
|
|
return redirect()->route('verification.notice');
|
|
}
|
|
|
|
if (! $request->expectsJson() && ! $request->inertia()) {
|
|
if ($hintKey = $this->resolveServerErrorHint($e)) {
|
|
$request->attributes->set('serverErrorHint', __($hintKey, [], app()->getLocale()));
|
|
}
|
|
}
|
|
|
|
return parent::render($request, $e);
|
|
}
|
|
|
|
private function configureSentryScope(): void
|
|
{
|
|
\Sentry\configureScope(function (Scope $scope): void {
|
|
$user = Auth::user();
|
|
|
|
if ($user) {
|
|
$userData = [
|
|
'id' => (string) $user->getAuthIdentifier(),
|
|
];
|
|
|
|
if (! empty($user->email)) {
|
|
$userData['email'] = $user->email;
|
|
}
|
|
|
|
if (! empty($user->username)) {
|
|
$userData['username'] = $user->username;
|
|
}
|
|
|
|
$scope->setUser($userData);
|
|
|
|
if (! empty($user->tenant_id)) {
|
|
$scope->setTag('tenant_id', (string) $user->tenant_id);
|
|
}
|
|
|
|
if (! empty($user->role)) {
|
|
$scope->setTag('user_role', $user->role);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private function shouldSkipSentry(Throwable $throwable): bool
|
|
{
|
|
if ($throwable instanceof ValidationException) {
|
|
return true;
|
|
}
|
|
|
|
$status = null;
|
|
|
|
if ($this->isHttpException($throwable)) {
|
|
$status = $this->toHttpException($throwable)->getStatusCode();
|
|
} elseif ($throwable instanceof HttpExceptionInterface) {
|
|
$status = $throwable->getStatusCode();
|
|
}
|
|
|
|
if (is_int($status) && $status < 500 && $status !== 429) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function buildGenericMessage(int $status): string
|
|
{
|
|
if ($status >= 500) {
|
|
return 'Something went wrong on our side. Please try again later.';
|
|
}
|
|
|
|
return 'Your request could not be processed. Please verify the details and try again.';
|
|
}
|
|
|
|
private function resolveServerErrorHint(Throwable $throwable): ?string
|
|
{
|
|
$status = $this->isHttpException($throwable)
|
|
? $this->toHttpException($throwable)->getStatusCode()
|
|
: 500;
|
|
|
|
if ($status < 500) {
|
|
return null;
|
|
}
|
|
|
|
if ($this->exceptionChainContains($throwable, [
|
|
QueryException::class,
|
|
DatabaseConnectionException::class,
|
|
PDOException::class,
|
|
'Doctrine\\DBAL\\Exception',
|
|
])) {
|
|
return 'marketing.server_error.hints.database';
|
|
}
|
|
|
|
if ($this->exceptionChainContains($throwable, [
|
|
FilesystemException::class,
|
|
'League\\Flysystem\\UnableToWriteFile',
|
|
'League\\Flysystem\\UnableToCreateDirectory',
|
|
'League\\Flysystem\\UnableToCheckExistence',
|
|
])) {
|
|
return 'marketing.server_error.hints.storage';
|
|
}
|
|
|
|
if ($this->exceptionChainContains($throwable, [
|
|
InvalidQueueException::class,
|
|
MaxAttemptsExceededException::class,
|
|
'RedisException',
|
|
'Predis\\Connection\\ConnectionException',
|
|
])) {
|
|
return 'marketing.server_error.hints.queue';
|
|
}
|
|
|
|
if ($this->exceptionChainContains($throwable, [
|
|
HttpClientConnectionException::class,
|
|
'GuzzleHttp\\Exception\\ConnectException',
|
|
'Psr\\Http\\Client\\NetworkExceptionInterface',
|
|
'Symfony\\Component\\HttpClient\\Exception\\TransportExceptionInterface',
|
|
])) {
|
|
return 'marketing.server_error.hints.network';
|
|
}
|
|
|
|
return 'marketing.server_error.hints.generic';
|
|
}
|
|
|
|
private function exceptionChainContains(Throwable $throwable, array $classNames): bool
|
|
{
|
|
do {
|
|
foreach ($classNames as $className) {
|
|
if ($this->throwableIsInstanceOf($throwable, $className)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
$throwable = $throwable->getPrevious();
|
|
} while ($throwable);
|
|
|
|
return false;
|
|
}
|
|
|
|
private function throwableIsInstanceOf(Throwable $throwable, string $className): bool
|
|
{
|
|
if ($className === '') {
|
|
return false;
|
|
}
|
|
|
|
if (! class_exists($className) && ! interface_exists($className)) {
|
|
return false;
|
|
}
|
|
|
|
return $throwable instanceof $className;
|
|
}
|
|
}
|