From 985271af2d8f3a2b2ce37ae422961a9529702f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Gangloff?= Date: Mon, 19 Aug 2024 16:34:08 +0200 Subject: [PATCH] feat: only a compatible connector can be linked to a Watchlist --- assets/utils/providers/index.tsx | 2 +- src/Config/Connector/ConnectorInterface.php | 3 + src/Config/Connector/GandiConnector.php | 35 ++++++- src/Config/Connector/OvhConnector.php | 58 +++++++++-- src/Controller/ConnectorController.php | 2 - src/Controller/WatchListController.php | 110 ++++++++++++++------ 6 files changed, 166 insertions(+), 44 deletions(-) diff --git a/assets/utils/providers/index.tsx b/assets/utils/providers/index.tsx index 6867436..888f7e1 100644 --- a/assets/utils/providers/index.tsx +++ b/assets/utils/providers/index.tsx @@ -7,7 +7,7 @@ export const helpGetTokenLink = (provider?: string) => { switch (provider) { case ConnectorProvider.OVH: return + href="https://api.ovh.com/createToken/?GET=/order/cart&GET=/order/cart/*&POST=/order/cart&POST=/order/cart/*&DELETE=/order/cart/*&GET=/domain/extensions"> {t`Retrieve a set of tokens from your customer account on the Provider's website`} diff --git a/src/Config/Connector/ConnectorInterface.php b/src/Config/Connector/ConnectorInterface.php index 46f473e..5ad92c0 100644 --- a/src/Config/Connector/ConnectorInterface.php +++ b/src/Config/Connector/ConnectorInterface.php @@ -3,6 +3,7 @@ namespace App\Config\Connector; use App\Entity\Domain; +use App\Entity\Tld; use Symfony\Contracts\HttpClient\HttpClientInterface; interface ConnectorInterface @@ -12,4 +13,6 @@ interface ConnectorInterface public function orderDomain(Domain $domain, bool $dryRun): void; public static function verifyAuthData(array $authData, HttpClientInterface $client): array; + + public function isSupported(Tld ...$tld): bool; } diff --git a/src/Config/Connector/GandiConnector.php b/src/Config/Connector/GandiConnector.php index d9196b7..62bcc00 100644 --- a/src/Config/Connector/GandiConnector.php +++ b/src/Config/Connector/GandiConnector.php @@ -3,18 +3,22 @@ namespace App\Config\Connector; use App\Entity\Domain; +use App\Entity\Tld; use http\Exception\InvalidArgumentException; use Symfony\Component\HttpClient\HttpOptions; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; readonly class GandiConnector implements ConnectorInterface { - private const BASE_URL = 'https://api.gandi.net/v5'; + private const BASE_URL = 'https://api.gandi.net'; public function __construct(private array $authData, private HttpClientInterface $client) { @@ -130,4 +134,33 @@ readonly class GandiConnector implements ConnectorInterface return $authDataReturned; } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + public function isSupported(Tld ...$tldList): bool + { + $authData = self::verifyAuthData($this->authData, $this->client); + + $response = $this->client->request('GET', '/v5/domain/tlds', (new HttpOptions()) + ->setAuthBearer($authData['token']) + ->setHeader('Accept', 'application/json') + ->setBaseUri(self::BASE_URL) + ->toArray())->toArray(); + + $supportedTldList = array_map(fn ($tld) => $tld['name'], $response); + + /** @var string $tldString */ + foreach (array_unique(array_map(fn (Tld $tld) => $tld->getTld(), $tldList)) as $tldString) { + if (!in_array($tldString, $supportedTldList)) { + return false; + } + } + + return true; + } } diff --git a/src/Config/Connector/OvhConnector.php b/src/Config/Connector/OvhConnector.php index f46021e..ca8b72a 100644 --- a/src/Config/Connector/OvhConnector.php +++ b/src/Config/Connector/OvhConnector.php @@ -3,7 +3,10 @@ namespace App\Config\Connector; use App\Entity\Domain; +use App\Entity\Tld; +use GuzzleHttp\Exception\ClientException; use Ovh\Api; +use Ovh\Exceptions\InvalidParameterException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -11,10 +14,15 @@ use Symfony\Contracts\HttpClient\HttpClientInterface; readonly class OvhConnector implements ConnectorInterface { public const REQUIRED_ROUTES = [ + [ + 'method' => 'GET', + 'path' => '/domain/extensions', + ], [ 'method' => 'GET', 'path' => '/order/cart', - ], [ + ], + [ 'method' => 'GET', 'path' => '/order/cart/*', ], @@ -162,14 +170,18 @@ readonly class OvhConnector implements ConnectorInterface $consumerKey ); - $res = $conn->get('/auth/currentCredential'); - if (null !== $res['expiration'] && new \DateTime($res['expiration']) < new \DateTime()) { - throw new \Exception('These credentials have expired'); - } + try { + $res = $conn->get('/auth/currentCredential'); + if (null !== $res['expiration'] && new \DateTime($res['expiration']) < new \DateTime()) { + throw new BadRequestHttpException('These credentials have expired'); + } - $status = $res['status']; - if ('validated' !== $status) { - throw new \Exception("The status of these credentials is not valid ($status)"); + $status = $res['status']; + if ('validated' !== $status) { + throw new BadRequestHttpException("The status of these credentials is not valid ($status)"); + } + } catch (ClientException $exception) { + throw new BadRequestHttpException($exception->getMessage()); } foreach (self::REQUIRED_ROUTES as $requiredRoute) { @@ -201,4 +213,34 @@ readonly class OvhConnector implements ConnectorInterface 'waiveRetractationPeriod' => $waiveRetractationPeriod, ]; } + + /** + * @throws InvalidParameterException + * @throws \JsonException + * @throws \Exception + */ + public function isSupported(Tld ...$tldList): bool + { + $authData = self::verifyAuthData($this->authData, $this->client); + + $conn = new Api( + $authData['appKey'], + $authData['appSecret'], + $authData['apiEndpoint'], + $authData['consumerKey'] + ); + + $supportedTldList = $conn->get('/domain/extensions', [ + 'ovhSubsidiary' => $authData['ovhSubsidiary'], + ]); + + /** @var string $tldString */ + foreach (array_unique(array_map(fn (Tld $tld) => $tld->getTld(), $tldList)) as $tldString) { + if (!in_array($tldString, $supportedTldList)) { + return false; + } + } + + return true; + } } diff --git a/src/Controller/ConnectorController.php b/src/Controller/ConnectorController.php index da556ea..b5de29b 100644 --- a/src/Controller/ConnectorController.php +++ b/src/Controller/ConnectorController.php @@ -12,7 +12,6 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; class ConnectorController extends AbstractController @@ -43,7 +42,6 @@ class ConnectorController extends AbstractController /** * @throws \Exception - * @throws TransportExceptionInterface */ #[Route( path: '/api/connectors', diff --git a/src/Controller/WatchListController.php b/src/Controller/WatchListController.php index 04a405c..1d6c5ca 100644 --- a/src/Controller/WatchListController.php +++ b/src/Controller/WatchListController.php @@ -2,7 +2,9 @@ namespace App\Controller; +use App\Config\Connector\ConnectorInterface; use App\Config\WebhookScheme; +use App\Entity\Connector; use App\Entity\Domain; use App\Entity\DomainEntity; use App\Entity\DomainEvent; @@ -40,6 +42,7 @@ use Symfony\Component\Notifier\Transport\AbstractTransportFactory; use Symfony\Component\Notifier\Transport\Dsn; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; class WatchListController extends AbstractController { @@ -47,40 +50,10 @@ class WatchListController extends AbstractController private readonly SerializerInterface $serializer, private readonly EntityManagerInterface $em, private readonly WatchListRepository $watchListRepository, - private readonly LoggerInterface $logger + private readonly LoggerInterface $logger, private readonly HttpClientInterface $httpClient ) { } - private function verifyWebhookDSN(WatchList $watchList): void - { - if (null !== $watchList->getWebhookDsn()) { - foreach ($watchList->getWebhookDsn() as $dsnString) { - try { - $dsn = new Dsn($dsnString); - } catch (InvalidArgumentException $exception) { - throw new BadRequestHttpException($exception->getMessage()); - } - - $scheme = $dsn->getScheme(); - $webhookScheme = WebhookScheme::tryFrom($scheme); - - if (null === $webhookScheme) { - throw new BadRequestHttpException("The DSN scheme ($scheme) is not supported"); - } - - $transportFactoryClass = $webhookScheme->getChatTransportFactory(); - /** @var AbstractTransportFactory $transportFactory */ - $transportFactory = new $transportFactoryClass(); - - try { - $transportFactory->create($dsn)->send((new TestChatNotification())->asChatMessage()); - } catch (\Throwable $exception) { - throw new BadRequestHttpException($exception->getMessage()); - } - } - } - } - /** * @throws \Exception */ @@ -147,8 +120,8 @@ class WatchListController extends AbstractController } $this->verifyWebhookDSN($watchList); + $this->verifyConnector($watchList, $watchList->getConnector()); - $user = $this->getUser(); $this->logger->info('User {username} registers a Watchlist ({token}).', [ 'username' => $user->getUserIdentifier(), 'token' => $watchList->getToken(), @@ -177,6 +150,78 @@ class WatchListController extends AbstractController return $user->getWatchLists(); } + private function verifyWebhookDSN(WatchList $watchList): void + { + if (null !== $watchList->getWebhookDsn()) { + foreach ($watchList->getWebhookDsn() as $dsnString) { + try { + $dsn = new Dsn($dsnString); + } catch (InvalidArgumentException $exception) { + throw new BadRequestHttpException($exception->getMessage()); + } + + $scheme = $dsn->getScheme(); + $webhookScheme = WebhookScheme::tryFrom($scheme); + + if (null === $webhookScheme) { + throw new BadRequestHttpException("The DSN scheme ($scheme) is not supported"); + } + + $transportFactoryClass = $webhookScheme->getChatTransportFactory(); + /** @var AbstractTransportFactory $transportFactory */ + $transportFactory = new $transportFactoryClass(); + + try { + $transportFactory->create($dsn)->send((new TestChatNotification())->asChatMessage()); + } catch (\Throwable $exception) { + throw new BadRequestHttpException($exception->getMessage()); + } + } + } + } + + /** + * @throws \Exception + */ + private function verifyConnector(WatchList $watchList, ?Connector $connector): void + { + /** @var User $user */ + $user = $this->getUser(); + + if (null !== $connector) { + if (!$user->getConnectors()->contains($connector)) { + $this->logger->notice('The Connector ({connector}) does not belong to the user.', [ + 'username' => $user->getUserIdentifier(), + 'connector' => $connector->getId(), + ]); + throw new AccessDeniedHttpException('You cannot create a Watchlist with a connector that does not belong to you'); + } + + $connectorProviderClass = $connector->getProvider()->getConnectorProvider(); + /** @var ConnectorInterface $connectorProvider */ + $connectorProvider = new $connectorProviderClass($connector->getAuthData(), $this->httpClient); + + $tldList = []; + /** @var Domain $domain */ + foreach ($watchList->getDomains()->getIterator() as $domain) { + $tld = $domain->getTld(); + if (!in_array($tld, $tldList)) { + $tldList[] = $tld; + } + } + + $supported = $connectorProvider->isSupported(...$tldList); + + if (!$supported) { + $this->logger->notice('The Connector ({connector}) does not support all TLDs in this Watchlist', [ + 'username' => $user->getUserIdentifier(), + 'connector' => $connector->getId(), + ]); + throw new BadRequestHttpException('This connector does not support all TLDs in this Watchlist'); + } + } + } + /** * @throws ORMException * @throws \Exception @@ -233,6 +278,7 @@ class WatchListController extends AbstractController } $this->verifyWebhookDSN($watchList); + $this->verifyConnector($watchList, $watchList->getConnector()); $this->logger->info('User {username} updates a Watchlist ({token}).', [ 'username' => $user->getUserIdentifier(),