refactor: change log level and add exceptions

This commit is contained in:
Maël Gangloff
2025-10-13 13:51:51 +02:00
parent efa56055d0
commit 0af22ff989
36 changed files with 253 additions and 135 deletions

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Service\Provider;
use App\Dto\Connector\DefaultProviderDto;
use App\Entity\Domain;
use App\Exception\Provider\UserNoExplicitConsentException;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* The typical flow of a provider will go as follows:
*
* MyProvider $provider; // gotten from DI
* $provider->authenticate($authData);
* $provider->orderDomain($domain, $dryRun);
*/
#[Autoconfigure(public: true)]
abstract class AbstractProvider
{
/** @var class-string */
protected string $dtoClass = DefaultProviderDto::class;
protected DefaultProviderDto $authData;
public function __construct(
protected CacheItemPoolInterface $cacheItemPool,
protected readonly DenormalizerInterface&NormalizerInterface $serializer,
private readonly ValidatorInterface $validator,
) {
}
/**
* Perform a static check of the connector data.
* To be valid, the data fields must match the Provider and the conditions must be accepted.
* User consent is checked here.
*
* @param array $authData raw authentication data as supplied by the user
*
* @return DefaultProviderDto a cleaned up version of the authentication data
*
* @throws HttpException when the user does not accept the necessary conditions
* @throws ExceptionInterface
*/
private function verifyAuthData(array $authData): DefaultProviderDto
{
/** @var DefaultProviderDto $data */
$data = $this->serializer->denormalize($this->verifyLegalAuthData($authData), $this->dtoClass);
$violations = $this->validator->validate($data);
if ($violations->count() > 0) {
throw new BadRequestHttpException((string) $violations);
}
return $data;
}
/**
* @param array $authData raw authentication data as supplied by the user
*
* @return array raw authentication data as supplied by the user
*
* @throws UserNoExplicitConsentException when the user does not accept the necessary conditions
*/
private function verifyLegalAuthData(array $authData): array
{
$acceptConditions = $authData['acceptConditions'];
$ownerLegalAge = $authData['ownerLegalAge'];
$waiveRetractationPeriod = $authData['waiveRetractationPeriod'];
if (true !== $acceptConditions
|| true !== $ownerLegalAge
|| true !== $waiveRetractationPeriod) {
throw new UserNoExplicitConsentException();
}
return $authData;
}
/**
* @throws \Exception when the registrar denies the authentication
*/
abstract protected function assertAuthentication(): void; // TODO use dedicated exception type
abstract public function orderDomain(Domain $domain, bool $dryRun): void;
public function isSupported(Domain ...$domainList): bool
{
$item = $this->getCachedTldList();
if (!$item->isHit()) {
$supportedTldList = $this->getSupportedTldList();
$item
->set($supportedTldList)
->expiresAfter(new \DateInterval('PT1H'));
$this->cacheItemPool->saveDeferred($item);
} else {
$supportedTldList = $item->get();
}
$extensionList = [];
foreach ($domainList as $domain) {
// We want to check the support of TLDs and SLDs here.
// For example, it is not enough for the Connector to support .fr for it to support the domain name example.asso.fr.
// It must support .asso.fr.
$extension = explode('.', $domain->getLdhName(), 2)[1];
if (!in_array($extension, $extensionList)) {
$extensionList[] = $extension;
}
}
foreach ($extensionList as $extension) {
if (!in_array($extension, $supportedTldList)) {
return false;
}
}
return true;
}
/**
* @throws ExceptionInterface
* @throws \Exception
*/
public function authenticate(array $authData): array
{
$this->authData = $this->verifyAuthData($authData);
$this->assertAuthentication();
return $authData;
}
abstract protected function getCachedTldList(): CacheItemInterface;
abstract protected function getSupportedTldList(): array;
}

View File

@@ -0,0 +1,223 @@
<?php
namespace App\Service\Provider;
use App\Dto\Connector\AutodnsProviderDto;
use App\Dto\Connector\DefaultProviderDto;
use App\Entity\Domain;
use App\Exception\Provider\InvalidLoginException;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\HttpClient\HttpOptions;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
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;
#[Autoconfigure(public: true)]
class AutodnsProvider extends AbstractProvider
{
protected string $dtoClass = AutodnsProviderDto::class;
/** @var AutodnsProviderDto */
protected DefaultProviderDto $authData;
public function __construct(
CacheItemPoolInterface $cacheItemPool,
DenormalizerInterface&NormalizerInterface $serializer,
private readonly HttpClientInterface $client,
ValidatorInterface $validator,
) {
parent::__construct($cacheItemPool, $serializer, $validator);
}
private const BASE_URL = 'https://api.autodns.com';
/**
* Order a domain name with the Gandi API.
*
* @throws \Exception
* @throws TransportExceptionInterface
* @throws DecodingExceptionInterface
*/
public function orderDomain(Domain $domain, bool $dryRun = false): void
{
$ldhName = $domain->getLdhName();
if (!$ldhName) {
throw new \InvalidArgumentException('Domain name cannot be null');
}
if ($dryRun) {
return;
}
$this->client->request(
'POST',
'/v1/domain',
(new HttpOptions())
->setAuthBasic($this->authData->username, $this->authData->password)
->setHeader('Accept', 'application/json')
->setHeader('X-Domainrobot-Context', (string) $this->authData->context)
->setBaseUri(self::BASE_URL)
->setJson([
'name' => $ldhName,
'ownerc' => [
'id' => $this->authData->contactid,
],
'adminc' => [
'id' => $this->authData->contactid,
],
'techc' => [
'id' => $this->authData->contactid,
],
'confirmOrder' => $this->authData->ownerConfirm,
'nameServers' => [
[
'name' => 'a.ns14.net',
],
[
'name' => 'b.ns14.net',
],
[
'name' => 'c.ns14.net',
],
[
'name' => 'd.ns14.net',
],
],
])
->toArray()
)->toArray();
}
/**
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
*/
public function registerZone(Domain $domain, bool $dryRun = false): void
{
$authData = $this->authData;
$ldhName = $domain->getLdhName();
if ($dryRun) {
return;
}
$zoneCheck = $this->client->request(
'POST',
'/v1/zone/_search?keys=name',
(new HttpOptions())
->setAuthBasic($authData->username, $authData->password)
->setHeader('Accept', 'application/json')
->setHeader('X-Domainrobot-Context', (string) $authData->context)
->setBaseUri(self::BASE_URL)
->setJson([
'filters' => [
[
'key' => 'name',
'value' => $ldhName,
'operator' => 'EQUAL',
],
],
])
->toArray()
)->toArray();
$responseDataIsEmpty = empty($zoneCheck['data']);
if ($responseDataIsEmpty) {
// The domain not yet exists in DNS Server, we create them
$this->client->request(
'POST',
'/v1/zone',
(new HttpOptions())
->setAuthBasic($authData->username, $authData->password)
->setHeader('Accept', 'application/json')
->setHeader('X-Domainrobot-Context', (string) $authData->context)
->setBaseUri(self::BASE_URL)
->setJson([
'origin' => $ldhName,
'main' => [
'address' => null, // $authData['dns_ip'],
],
'soa' => [
'refresh' => 3600,
'retry' => 7200,
'expire' => 604800,
'ttl' => 600,
],
'action' => 'COMPLETE',
'wwwInclude' => true,
'nameServers' => [
[
'name' => 'a.ns14.net',
],
[
'name' => 'b.ns14.net',
],
[
'name' => 'c.ns14.net',
],
[
'name' => 'd.ns14.net',
],
],
])
->toArray()
)->toArray();
}
}
public function isSupported(Domain ...$domainList): bool
{
return true;
}
protected function getSupportedTldList(): array
{
return [];
}
/**
* @throws InvalidArgumentException
*/
protected function getCachedTldList(): CacheItemInterface
{
return $this->cacheItemPool->getItem('app.provider.autodns.supported-tld');
}
/**
* @throws TransportExceptionInterface
* @throws InvalidLoginException
*/
protected function assertAuthentication(): void
{
$response = $this->client->request(
'GET',
'/v1/hello',
(new HttpOptions())
->setAuthBasic($this->authData->username, $this->authData->password)
->setHeader('Accept', 'application/json')
->setHeader('X-Domainrobot-Context', (string) $this->authData->context)
->setBaseUri(self::BASE_URL)
->toArray()
);
if (Response::HTTP_OK !== $response->getStatusCode()) {
throw InvalidLoginException::fromIdentifier($this->authData->username);
}
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Service\Provider;
interface CheckDomainProviderInterface
{
public function checkDomains(string ...$domains): array;
}

View File

@@ -0,0 +1,202 @@
<?php
namespace App\Service\Provider;
use App\Dto\Connector\DefaultProviderDto;
use App\Dto\Connector\EppClientProviderDto;
use App\Entity\Domain;
use App\Exception\Provider\EppContactIsAvailableException;
use Metaregistrar\EPP\eppCheckContactRequest;
use Metaregistrar\EPP\eppCheckContactResponse;
use Metaregistrar\EPP\eppCheckDomainRequest;
use Metaregistrar\EPP\eppCheckDomainResponse;
use Metaregistrar\EPP\eppConnection;
use Metaregistrar\EPP\eppContactHandle;
use Metaregistrar\EPP\eppCreateDomainRequest;
use Metaregistrar\EPP\eppDomain;
use Metaregistrar\EPP\eppException;
use Metaregistrar\EPP\eppHelloRequest;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class EppClientProvider extends AbstractProvider implements CheckDomainProviderInterface
{
protected string $dtoClass = EppClientProviderDto::class;
/** @var EppClientProviderDto */
protected DefaultProviderDto $authData;
private ?eppConnection $eppClient = null;
public function __construct(
CacheItemPoolInterface $cacheItemPool,
DenormalizerInterface&NormalizerInterface $serializer,
ValidatorInterface $validator,
) {
parent::__construct($cacheItemPool, $serializer, $validator);
}
protected function assertAuthentication(): void
{
$this->connect();
$this->eppClient->login();
$this->eppClient->request(new eppHelloRequest());
$contacts = [new eppContactHandle($this->authData->domain->registrant, eppContactHandle::CONTACT_TYPE_REGISTRANT)];
foreach ($this->authData->domain->contacts as $role => $roid) {
$contacts[] = new eppContactHandle($roid, $role);
}
/** @var eppCheckContactResponse $resp */
$resp = $this->eppClient->request(new eppCheckContactRequest($contacts));
foreach ($resp->getCheckedContacts() as $contact => $available) {
if ($available) {
throw EppContactIsAvailableException::fromContact($contact);
}
}
$this->eppClient->logout();
$this->eppClient->disconnect();
}
/**
* @throws eppException
*/
public function orderDomain(Domain $domain, bool $dryRun): void
{
$this->connect();
$d = new eppDomain($domain->getLdhName());
$d->setRegistrant($this->authData->domain->registrant);
$d->setPeriodUnit($this->authData->domain->unit);
$d->setPeriod($this->authData->domain->period);
$d->setAuthorisationCode($this->authData->domain->password);
foreach ($this->authData->domain->contacts as $type => $contact) {
$d->addContact(new eppContactHandle($contact, $type));
}
if (!$dryRun) {
$this->eppClient->request(new eppCreateDomainRequest($d));
}
$this->eppClient->logout();
$this->disconnect();
}
/**
* @throws InvalidArgumentException
*/
protected function getCachedTldList(): CacheItemInterface
{
return $this->cacheItemPool->getItem('app.provider.epp.supported-tld');
}
protected function getSupportedTldList(): array
{
return [];
}
public function isSupported(Domain ...$domainList): bool
{
if (0 === count($domainList)) {
return true;
}
$tld = $domainList[0]->getTld();
foreach ($domainList as $domain) {
if ($domain->getTld() !== $tld) {
return false;
}
}
return true;
}
/**
* @return string[]
*
* @throws eppException
*/
public function checkDomains(string ...$domains): array
{
$this->connect();
$this->eppClient->login();
$check = new eppCheckDomainRequest($domains);
/** @var eppCheckDomainResponse $response */
$response = $this->eppClient->request($check);
$checkedDomains = $response->getCheckedDomains();
$return = array_map(
fn (array $d) => $d['domainname'],
array_filter($checkedDomains, fn (array $d) => true === $d['available'])
);
$this->eppClient->logout();
$this->eppClient->disconnect();
return $return;
}
/**
* @throws eppException
* @throws ExceptionInterface
*/
private function connect(): void
{
if ($this->eppClient && $this->eppClient->isConnected()) {
return;
}
$conn = new eppConnection(false, null);
$conn->setHostname($this->authData->hostname);
$conn->setVersion($this->authData->version);
$conn->setLanguage($this->authData->language);
$conn->setPort($this->authData->port);
$conn->setUsername($this->authData->auth->username);
$conn->setPassword($this->authData->auth->password);
$ssl = (array) $this->serializer->normalize($this->authData->auth->ssl, 'json');
if (isset($this->authData->file_certificate_pem, $this->authData->file_certificate_key)) {
$conn->setSslContext(stream_context_create(['ssl' => [
...$ssl,
'local_cert' => $this->authData->file_certificate_pem,
'local_pk' => $this->authData->file_certificate_key,
]]));
} else {
unset($ssl['local_cert'], $ssl['local_pk']);
$conn->setSslContext(stream_context_create(['ssl' => $ssl]));
}
$conn->setExtensions($this->authData->extURI);
$conn->setServices($this->authData->objURI);
$conn->connect();
$this->eppClient = $conn;
}
private function disconnect(): void
{
$this->eppClient->disconnect();
}
public function __destruct()
{
$this->disconnect();
}
public static function buildEppCertificateFolder(string $projectDir, string $connectorId): string
{
return sprintf('%s/%s/%s/', $projectDir, 'var/epp-certificates', $connectorId);
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace App\Service\Provider;
use App\Dto\Connector\DefaultProviderDto;
use App\Dto\Connector\GandiProviderDto;
use App\Entity\Domain;
use App\Exception\Provider\DomainOrderFailedExeption;
use App\Exception\Provider\InvalidLoginException;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\HttpClient\HttpOptions;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
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;
#[Autoconfigure(public: true)]
class GandiProvider extends AbstractProvider
{
protected string $dtoClass = GandiProviderDto::class;
/** @var GandiProviderDto */
protected DefaultProviderDto $authData;
private const BASE_URL = 'https://api.gandi.net';
public function __construct(
CacheItemPoolInterface $cacheItemPool,
private readonly HttpClientInterface $client,
DenormalizerInterface&NormalizerInterface $serializer,
ValidatorInterface $validator,
) {
parent::__construct($cacheItemPool, $serializer, $validator);
}
/**
* Order a domain name with the Gandi API.
*
* @throws \Exception
* @throws TransportExceptionInterface
* @throws DecodingExceptionInterface
*/
public function orderDomain(Domain $domain, bool $dryRun = false): void
{
$ldhName = $domain->getLdhName();
if (!$ldhName) {
throw new \InvalidArgumentException('Domain name cannot be null');
}
$user = $this->client->request('GET', '/v5/organization/user-info', (new HttpOptions())
->setAuthBearer($this->authData->token)
->setHeader('Accept', 'application/json')
->setBaseUri(self::BASE_URL)
->toArray()
)->toArray();
$httpOptions = (new HttpOptions())
->setAuthBearer($this->authData->token)
->setHeader('Accept', 'application/json')
->setBaseUri(self::BASE_URL)
->setHeader('Dry-Run', $dryRun ? '1' : '0')
->setJson([
'fqdn' => $ldhName,
'owner' => [
'email' => $user['email'],
'given' => $user['firstname'],
'family' => $user['lastname'],
'streetaddr' => $user['streetaddr'],
'zip' => $user['zip'],
'city' => $user['city'],
'state' => $user['state'],
'phone' => $user['phone'],
'country' => $user['country'],
'type' => 'individual',
],
'tld_period' => 'golive',
]);
if ($this->authData->sharingId) {
$httpOptions->setQuery([
'sharing_id' => $this->authData->sharingId,
]);
}
$res = $this->client->request('POST', '/domain/domains', $httpOptions->toArray());
if ((!$dryRun && Response::HTTP_ACCEPTED !== $res->getStatusCode())
|| ($dryRun && Response::HTTP_OK !== $res->getStatusCode())) {
throw new DomainOrderFailedExeption($res->toArray()['message']);
}
}
/**
* @throws TransportExceptionInterface
* @throws InvalidLoginException
*/
protected function assertAuthentication(): void
{
$response = $this->client->request('GET', '/v5/organization/user-info', (new HttpOptions())
->setAuthBearer($this->authData->token)
->setHeader('Accept', 'application/json')
->setBaseUri(self::BASE_URL)
->toArray()
);
if (Response::HTTP_OK !== $response->getStatusCode()) {
throw new InvalidLoginException();
}
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
*/
protected function getSupportedTldList(): array
{
$response = $this->client->request('GET', '/v5/domain/tlds', (new HttpOptions())
->setAuthBearer($this->authData->token)
->setHeader('Accept', 'application/json')
->setBaseUri(self::BASE_URL)
->toArray())->toArray();
return array_map(fn ($tld) => $tld['name'], $response);
}
/**
* @throws \Psr\Cache\InvalidArgumentException
*/
protected function getCachedTldList(): CacheItemInterface
{
return $this->cacheItemPool->getItem('app.provider.gandi.supported-tld');
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Service\Provider;
use App\Dto\Connector\DefaultProviderDto;
use App\Dto\Connector\NameComProviderDto;
use App\Entity\Domain;
use App\Exception\Provider\InvalidLoginException;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\HttpClient\HttpOptions;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[Autoconfigure(public: true)]
class NameComProvider extends AbstractProvider
{
protected string $dtoClass = NameComProviderDto::class;
/** @var NameComProviderDto */
protected DefaultProviderDto $authData;
public function __construct(
CacheItemPoolInterface $cacheItemPool,
private readonly HttpClientInterface $client,
private readonly KernelInterface $kernel,
DenormalizerInterface&NormalizerInterface $serializer,
ValidatorInterface $validator,
) {
parent::__construct($cacheItemPool, $serializer, $validator);
}
private const BASE_URL = 'https://api.name.com';
private const DEV_BASE_URL = 'https://api.dev.name.com';
/**
* Order a domain name with the Gandi API.
*
* @throws \Exception
* @throws TransportExceptionInterface
* @throws DecodingExceptionInterface
*/
public function orderDomain(Domain $domain, bool $dryRun = false): void
{
$ldhName = $domain->getLdhName();
if (!$ldhName) {
throw new \InvalidArgumentException('Domain name cannot be null');
}
$this->client->request(
'POST',
'/v4/domains',
(new HttpOptions())
->setHeader('Accept', 'application/json')
->setAuthBasic($this->authData->username, $this->authData->token)
->setBaseUri($dryRun ? self::DEV_BASE_URL : self::BASE_URL)
->setJson([
'domain' => [
[
'domainName' => $domain->getLdhName(),
'locked' => false,
'autorenewEnabled' => false,
],
'purchaseType' => 'registration',
'years' => 1,
// 'tldRequirements' => []
],
])
->toArray()
)->toArray();
}
public function isSupported(Domain ...$domainList): bool
{
return true;
}
protected function getSupportedTldList(): array
{
return [];
}
/**
* @throws InvalidArgumentException
*/
protected function getCachedTldList(): CacheItemInterface
{
return $this->cacheItemPool->getItem('app.provider.namecom.supported-tld');
}
/**
* @throws TransportExceptionInterface
* @throws InvalidLoginException
*/
protected function assertAuthentication(): void
{
$response = $this->client->request(
'GET',
'/v4/hello',
(new HttpOptions())
->setHeader('Accept', 'application/json')
->setAuthBasic($this->authData->username, $this->authData->token)
->setBaseUri($this->kernel->isDebug() ? self::DEV_BASE_URL : self::BASE_URL)
->toArray()
);
if (Response::HTTP_OK !== $response->getStatusCode()) {
throw InvalidLoginException::fromIdentifier($this->authData->username);
}
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Service\Provider;
use App\Dto\Connector\DefaultProviderDto;
use App\Dto\Connector\NamecheapProviderDto;
use App\Entity\Domain;
use App\Exception\Provider\NamecheapRequiresAddressException;
use App\Exception\Provider\ProviderGenericErrorException;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[Autoconfigure(public: true)]
class NamecheapProvider extends AbstractProvider
{
protected string $dtoClass = NamecheapProviderDto::class;
/** @var NamecheapProviderDto */
protected DefaultProviderDto $authData;
public const BASE_URL = 'https://api.namecheap.com/xml.response';
public const SANDBOX_BASE_URL = 'https://api.sandbox.namecheap.com/xml.response';
public function __construct(
CacheItemPoolInterface $cacheItemPool,
private readonly HttpClientInterface $client,
private readonly string $outgoingIp,
DenormalizerInterface&NormalizerInterface $serializer,
ValidatorInterface $validator,
) {
parent::__construct($cacheItemPool, $serializer, $validator);
}
/**
* @throws \Exception
* @throws TransportExceptionInterface
*/
public function orderDomain(Domain $domain, $dryRun): void
{
$addresses = $this->call('namecheap.users.address.getList', [], $dryRun)->AddressGetListResult->List;
if (count($addresses) < 1) {
throw new NamecheapRequiresAddressException();
}
$addressId = (string) $addresses->attributes()['AddressId'];
$address = (array) $this->call('namecheap.users.address.getinfo', ['AddressId' => $addressId], $dryRun)->GetAddressInfoResult;
if (empty($address['PostalCode'])) {
$address['PostalCode'] = $address['Zip'];
}
$domainAddresses = [];
self::mergePrefixKeys('Registrant', $address, $domainAddresses);
self::mergePrefixKeys('Tech', $address, $domainAddresses);
self::mergePrefixKeys('Admin', $address, $domainAddresses);
self::mergePrefixKeys('AuxBilling', $address, $domainAddresses);
$this->call('namecheap.domains.create', array_merge([
'DomainName' => $domain->getLdhName(), // Domain name to register
'Years' => 1, // Number of years to register
'AddFreeWhoisguard' => 'yes', // Adds free domain privacy for the domain
'WGEnabled' => 'yes', // Enables free domain privacy for the domain
], $domainAddresses), $dryRun);
}
private static function mergePrefixKeys(string $prefix, array|object $src, array &$dest): void
{
foreach ($src as $key => $value) {
$dest[$prefix.$key] = $value;
}
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
* @throws \Exception
*/
private function call(string $command, array $parameters = [], bool $dryRun = true): object
{
$actualParams = array_merge([
'Command' => $command,
'UserName' => $this->authData->ApiUser,
'ApiUser' => $this->authData->ApiUser,
'ApiKey' => $this->authData->ApiKey,
'ClientIp' => $this->outgoingIp,
], $parameters);
$response = $this->client->request('POST', $dryRun ? self::SANDBOX_BASE_URL : self::BASE_URL, [
'query' => $actualParams,
]);
$data = new \SimpleXMLElement($response->getContent());
if ($data->Errors->Error) {
throw new ProviderGenericErrorException($data->Errors->Error);
}
return $data->CommandResponse;
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
* @throws NamecheapRequiresAddressException
*/
protected function assertAuthentication(): void
{
$addresses = $this->call('namecheap.users.address.getList', [], false)->AddressGetListResult->List;
if (count($addresses) < 1) {
throw new NamecheapRequiresAddressException();
}
}
/**
* @throws InvalidArgumentException
*/
protected function getCachedTldList(): CacheItemInterface
{
return $this->cacheItemPool->getItem('app.provider.namecheap.supported-tld');
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
*/
protected function getSupportedTldList(): array
{
$supported = [];
$tlds = $this->call('namecheap.domains.gettldlist', [], false)->Tlds->Tld;
for ($i = 0; $i < $tlds->count(); ++$i) {
$supported[] = (string) $tlds[$i]['Name'];
}
return $supported;
}
}

View File

@@ -0,0 +1,215 @@
<?php
namespace App\Service\Provider;
use App\Dto\Connector\DefaultProviderDto;
use App\Dto\Connector\OvhProviderDto;
use App\Entity\Domain;
use App\Exception\Provider\DomainOrderFailedExeption;
use App\Exception\Provider\ExpiredLoginException;
use App\Exception\Provider\InvalidLoginStatusException;
use App\Exception\Provider\PermissionErrorException;
use App\Exception\Provider\ProviderGenericErrorException;
use GuzzleHttp\Exception\ClientException;
use Ovh\Api;
use Ovh\Exceptions\InvalidParameterException;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
#[Autoconfigure(public: true)]
class OvhProvider extends AbstractProvider
{
protected string $dtoClass = OvhProviderDto::class;
/** @var OvhProviderDto */
protected DefaultProviderDto $authData;
public const REQUIRED_ROUTES = [
[
'method' => 'GET',
'path' => '/domain/extensions',
],
[
'method' => 'GET',
'path' => '/order/cart',
],
[
'method' => 'GET',
'path' => '/order/cart/*',
],
[
'method' => 'POST',
'path' => '/order/cart',
],
[
'method' => 'POST',
'path' => '/order/cart/*',
],
[
'method' => 'DELETE',
'path' => '/order/cart/*',
],
];
public function __construct(
CacheItemPoolInterface $cacheItemPool,
DenormalizerInterface&NormalizerInterface $serializer,
ValidatorInterface $validator,
) {
parent::__construct($cacheItemPool, $serializer, $validator);
}
/**
* Order a domain name with the OVH API.
*
* @throws \Exception
*/
public function orderDomain(Domain $domain, bool $dryRun = false): void
{
$ldhName = $domain->getLdhName();
if (!$ldhName) {
throw new \InvalidArgumentException('Domain name cannot be null');
}
$acceptConditions = $this->authData->acceptConditions;
$ownerLegalAge = $this->authData->ownerLegalAge;
$waiveRetractationPeriod = $this->authData->waiveRetractationPeriod;
$conn = new Api(
$this->authData->appKey,
$this->authData->appSecret,
$this->authData->apiEndpoint,
$this->authData->consumerKey,
);
$cart = $conn->post('/order/cart', [
'ovhSubsidiary' => $this->authData->ovhSubsidiary,
'description' => 'Domain Watchdog',
]);
$cartId = $cart['cartId'];
$offers = $conn->get("/order/cart/{$cartId}/domain", [
'domain' => $ldhName,
]);
$pricingModes = ['create-default'];
if ('create-default' !== $this->authData->pricingMode) {
$pricingModes[] = $this->authData->pricingMode;
}
$offer = array_filter($offers, fn ($offer) => 'create' === $offer['action']
&& true === $offer['orderable']
&& in_array($offer['pricingMode'], $pricingModes)
);
if (empty($offer)) {
$conn->delete("/order/cart/{$cartId}");
throw new DomainOrderFailedExeption();
}
$item = $conn->post("/order/cart/{$cartId}/domain", [
'domain' => $ldhName,
'duration' => 'P1Y',
]);
$itemId = $item['itemId'];
// $conn->get("/order/cart/{$cartId}/summary");
$conn->post("/order/cart/{$cartId}/assign");
$conn->get("/order/cart/{$cartId}/item/{$itemId}/requiredConfiguration");
$configuration = [
'ACCEPT_CONDITIONS' => $acceptConditions,
'OWNER_LEGAL_AGE' => $ownerLegalAge,
];
foreach ($configuration as $label => $value) {
$conn->post("/order/cart/{$cartId}/item/{$itemId}/configuration", [
'cartId' => $cartId,
'itemId' => $itemId,
'label' => $label,
'value' => $value,
]);
}
$conn->get("/order/cart/{$cartId}/checkout");
if ($dryRun) {
return;
}
$conn->post("/order/cart/{$cartId}/checkout", [
'autoPayWithPreferredPaymentMethod' => true,
'waiveRetractationPeriod' => $waiveRetractationPeriod,
]);
}
protected function assertAuthentication(): void
{
$conn = new Api(
$this->authData->appKey,
$this->authData->appSecret,
$this->authData->apiEndpoint,
$this->authData->consumerKey,
);
try {
$res = $conn->get('/auth/currentCredential');
if (null !== $res['expiration'] && new \DateTimeImmutable($res['expiration']) < new \DateTimeImmutable()) {
throw ExpiredLoginException::fromIdentifier($this->authData->appKey);
}
$status = $res['status'];
if ('validated' !== $status) {
throw InvalidLoginStatusException::fromStatus($status);
}
} catch (ClientException $exception) {
throw new ProviderGenericErrorException($exception->getMessage());
}
foreach (self::REQUIRED_ROUTES as $requiredRoute) {
$ok = false;
foreach ($res['rules'] as $allowedRoute) {
if (
$requiredRoute['method'] === $allowedRoute['method']
&& fnmatch($allowedRoute['path'], $requiredRoute['path'])
) {
$ok = true;
}
}
if (!$ok) {
throw PermissionErrorException::fromIdentifier($this->authData->appKey);
}
}
}
/**
* @throws InvalidParameterException
* @throws \JsonException
* @throws \Exception
*/
protected function getSupportedTldList(): array
{
$conn = new Api(
$this->authData->appKey,
$this->authData->appSecret,
$this->authData->apiEndpoint,
$this->authData->consumerKey,
);
return $conn->get('/domain/extensions', [
'ovhSubsidiary' => $this->authData->ovhSubsidiary,
]);
}
/**
* @throws InvalidArgumentException
*/
protected function getCachedTldList(): CacheItemInterface
{
return $this->cacheItemPool->getItem('app.provider.ovh.supported-tld');
}
}