Merged master

This commit is contained in:
Vincent
2024-09-18 13:37:07 +02:00
139 changed files with 8418 additions and 2409 deletions

View File

@@ -2,22 +2,19 @@
namespace App\Config;
use App\Service\Connector\OvhConnector;
use App\Service\Connector\GandiConnector;
use App\Service\Connector\NamecheapConnector;
use App\Config\Provider\GandiProvider;
use App\Config\Provider\OvhProvider;
enum ConnectorProvider: string
{
case OVH = 'ovh';
case GANDI = 'gandi';
case NAMECHEAP = 'namecheap';
public function getConnectorProvider(): string
{
return match ($this) {
ConnectorProvider::OVH => OvhConnector::class,
ConnectorProvider::GANDI => GandiConnector::class,
ConnectorProvider::NAMECHEAP => NamecheapConnector::class
ConnectorProvider::OVH => OvhProvider::class,
ConnectorProvider::GANDI => GandiProvider::class
};
}
}

View File

@@ -5,4 +5,5 @@ namespace App\Config;
enum TriggerAction: string
{
case SendEmail = 'email';
case SendChat = 'chat';
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Config;
use Symfony\Component\Notifier\Bridge\Discord\DiscordTransportFactory;
use Symfony\Component\Notifier\Bridge\Engagespot\EngagespotTransportFactory;
use Symfony\Component\Notifier\Bridge\GoogleChat\GoogleChatTransportFactory;
use Symfony\Component\Notifier\Bridge\Mattermost\MattermostTransportFactory;
use Symfony\Component\Notifier\Bridge\MicrosoftTeams\MicrosoftTeamsTransportFactory;
use Symfony\Component\Notifier\Bridge\Ntfy\NtfyTransportFactory;
use Symfony\Component\Notifier\Bridge\Pushover\PushoverTransportFactory;
use Symfony\Component\Notifier\Bridge\RocketChat\RocketChatTransportFactory;
use Symfony\Component\Notifier\Bridge\Slack\SlackTransportFactory;
use Symfony\Component\Notifier\Bridge\Telegram\TelegramTransportFactory;
use Symfony\Component\Notifier\Bridge\Zulip\ZulipTransportFactory;
enum WebhookScheme: string
{
case DISCORD = 'discord';
case GOOGLE_CHAT = 'googlechat';
case MATTERMOST = 'mattermost';
case MICROSOFT_TEAMS = 'microsoftteams';
case ROCKET_CHAT = 'rocketchat';
case SLACK = 'slack';
case TELEGRAM = 'telegram';
case ZULIP = 'zulip';
case PUSHOVER = 'pushover';
case NTFY = 'ntfy';
case ENGAGESPOT = 'engagespot';
public function getChatTransportFactory(): string
{
return match ($this) {
WebhookScheme::DISCORD => DiscordTransportFactory::class,
WebhookScheme::GOOGLE_CHAT => GoogleChatTransportFactory::class,
WebhookScheme::MATTERMOST => MattermostTransportFactory::class,
WebhookScheme::MICROSOFT_TEAMS => MicrosoftTeamsTransportFactory::class,
WebhookScheme::ROCKET_CHAT => RocketChatTransportFactory::class,
WebhookScheme::SLACK => SlackTransportFactory::class,
WebhookScheme::TELEGRAM => TelegramTransportFactory::class,
WebhookScheme::ZULIP => ZulipTransportFactory::class,
WebhookScheme::PUSHOVER => PushoverTransportFactory::class,
WebhookScheme::NTFY => NtfyTransportFactory::class,
WebhookScheme::ENGAGESPOT => EngagespotTransportFactory::class
};
}
}

View File

@@ -2,17 +2,17 @@
namespace App\Controller;
use App\Config\Connector\ConnectorInterface;
use App\Entity\Connector;
use App\Entity\User;
use App\Service\Connector\AbstractProvider;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
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 +43,6 @@ class ConnectorController extends AbstractController
/**
* @throws \Exception
* @throws TransportExceptionInterface
*/
#[Route(
path: '/api/connectors',
@@ -69,10 +68,10 @@ class ConnectorController extends AbstractController
]);
if (null === $provider) {
throw new \Exception('Provider not found');
throw new BadRequestHttpException('Provider not found');
}
/** @var ConnectorInterface $connectorProviderClass */
/** @var AbstractProvider $connectorProviderClass */
$connectorProviderClass = $provider->getConnectorProvider();
$authData = $connectorProviderClass::verifyAuthData($connector->getAuthData(), $client);

View File

@@ -4,7 +4,7 @@ namespace App\Controller;
use App\Entity\Domain;
use App\Entity\WatchList;
use App\Message\ProcessDomainTrigger;
use App\Message\SendDomainEventNotif;
use App\Repository\DomainRepository;
use App\Service\RDAPService;
use Psr\Log\LoggerInterface;
@@ -25,7 +25,7 @@ class DomainRefreshController extends AbstractController
private readonly RDAPService $RDAPService,
private readonly RateLimiterFactory $rdapRequestsLimiter,
private readonly MessageBusInterface $bus,
private readonly LoggerInterface $logger
private readonly LoggerInterface $logger, private readonly KernelInterface $kernel
) {
}
@@ -35,8 +35,9 @@ class DomainRefreshController extends AbstractController
* @throws ExceptionInterface
* @throws \Exception
* @throws HttpExceptionInterface
* @throws \Throwable
*/
public function __invoke(string $ldhName, KernelInterface $kernel): ?Domain
public function __invoke(string $ldhName, KernelInterface $kernel): Domain
{
$idnDomain = strtolower(idn_to_ascii($ldhName));
$userId = $this->getUser()->getUserIdentifier();
@@ -53,7 +54,8 @@ class DomainRefreshController extends AbstractController
if (null !== $domain
&& !$domain->getDeleted()
&& ($domain->getUpdatedAt()->diff(new \DateTimeImmutable('now'))->days < 7)
&& !$this->RDAPService::isToBeWatchClosely($domain, $domain->getUpdatedAt())
&& !$this->RDAPService::isToBeWatchClosely($domain)
&& !$this->kernel->isDebug()
) {
$this->logger->info('It is not necessary to update the information of the domain name {idnDomain} with the RDAP protocol.', [
'idnDomain' => $idnDomain,
@@ -79,7 +81,7 @@ class DomainRefreshController extends AbstractController
/** @var WatchList $watchList */
foreach ($watchLists as $watchList) {
$this->bus->dispatch(new ProcessDomainTrigger($watchList->getToken(), $domain->getLdhName(), $updatedAt));
$this->bus->dispatch(new SendDomainEventNotif($watchList->getToken(), $domain->getLdhName(), $updatedAt));
}
return $domain;

View File

@@ -75,6 +75,25 @@ class RegistrationController extends AbstractController
)
);
if (false === (bool) $this->getParameter('registration_verify_email')) {
$user->setVerified(true);
} else {
$email = $this->emailVerifier->sendEmailConfirmation('app_verify_email', $user,
(new TemplatedEmail())
->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail())
->locale('en')
->subject('Please Confirm your Email')
->htmlTemplate('emails/success/confirmation_email.html.twig')
);
$signedUrl = (string) $email->getContext()['signedUrl'];
$this->logger->notice('The validation link for user {username} is {signedUrl}', [
'username' => $user->getUserIdentifier(),
'signedUrl' => $signedUrl,
]);
}
$this->em->persist($user);
$this->em->flush();
@@ -82,15 +101,6 @@ class RegistrationController extends AbstractController
'username' => $user->getUserIdentifier(),
]);
$this->emailVerifier->sendEmailConfirmation('app_verify_email', $user,
(new TemplatedEmail())
->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail())
->locale('en')
->subject('Please Confirm your Email')
->htmlTemplate('emails/success/confirmation_email.html.twig')
);
return new Response(null, 201);
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Controller;
use App\Entity\Statistics;
use App\Repository\DomainRepository;
use App\Repository\WatchListRepository;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpKernel\KernelInterface;
class StatisticsController extends AbstractController
{
public function __construct(
private readonly CacheItemPoolInterface $pool,
private readonly DomainRepository $domainRepository,
private readonly WatchListRepository $watchListRepository,
private readonly KernelInterface $kernel
) {
}
/**
* @throws InvalidArgumentException
*/
public function __invoke(): Statistics
{
$stats = new Statistics();
$stats
->setRdapQueries($this->pool->getItem('stats.rdap_queries.count')->get() ?? 0)
->setDomainPurchased($this->pool->getItem('stats.domain.purchased')->get() ?? 0)
->setDomainPurchaseFailed($this->pool->getItem('stats.domain.purchase.failed')->get() ?? 0)
->setAlertSent($this->pool->getItem('stats.alert.sent')->get() ?? 0)
->setDomainTracked(
$this->getCachedItem('stats.domain.tracked', fn () => $this->watchListRepository->createQueryBuilder('w')
->join('w.domains', 'd')
->select('COUNT(DISTINCT d.ldhName)')
->where('d.deleted = FALSE')
->getQuery()->getSingleColumnResult()[0])
)
->setDomainCount(
$this->getCachedItem('stats.domain.count', fn () => $this->domainRepository->createQueryBuilder('d')
->join('d.tld', 't')
->select('t.tld tld')
->addSelect('COUNT(d.ldhName) AS domain')
->addGroupBy('t.tld')
->where('d.deleted = FALSE')
->orderBy('domain', 'DESC')
->setMaxResults(5)
->getQuery()->getArrayResult())
)
->setDomainCountTotal(
$this->getCachedItem('stats.domain.total', fn () => $this->domainRepository->count(['deleted' => false])
));
return $stats;
}
/**
* @throws InvalidArgumentException
*/
private function getCachedItem(string $key, callable $getItemFunction)
{
$item = $this->pool->getItem($key);
if (!$item->isHit() || $this->kernel->isDebug()) {
$value = $getItemFunction();
$item
->set($value)
->expiresAfter(new \DateInterval('PT6H'));
$this->pool->save($item);
return $value;
} else {
return $item->get();
}
}
}

View File

@@ -2,14 +2,19 @@
namespace App\Controller;
use App\Entity\Connector;
use App\Entity\Domain;
use App\Entity\DomainEntity;
use App\Entity\DomainEvent;
use App\Entity\User;
use App\Entity\WatchList;
use App\Notifier\TestChatNotification;
use App\Repository\WatchListRepository;
use App\Service\ChatNotificationService;
use App\Service\Connector\AbstractProvider;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Exception\ORMException;
use Eluceo\iCal\Domain\Entity\Attendee;
use Eluceo\iCal\Domain\Entity\Calendar;
use Eluceo\iCal\Domain\Entity\Event;
@@ -22,6 +27,7 @@ use Eluceo\iCal\Domain\ValueObject\Timestamp;
use Eluceo\iCal\Presentation\Component\Property;
use Eluceo\iCal\Presentation\Component\Property\Value\TextValue;
use Eluceo\iCal\Presentation\Factory\CalendarFactory;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Sabre\VObject\EofException;
use Sabre\VObject\InvalidDataException;
@@ -31,8 +37,11 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class WatchListController extends AbstractController
{
@@ -40,7 +49,11 @@ 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 readonly CacheItemPoolInterface $cacheItemPool,
private readonly KernelInterface $kernel,
private readonly ChatNotificationService $chatNotificationService
) {
}
@@ -69,8 +82,8 @@ class WatchListController extends AbstractController
* This policy guarantees the equal probability of obtaining a domain name if it is requested by several users.
*/
if ($this->getParameter('limited_features')) {
if ($watchList->getDomains()->count() >= (int) $this->getParameter('limit_max_watchlist_domains')) {
$this->logger->notice('User {username} tried to create a Watchlist. However, the maximum number of domains has been reached for this Watchlist', [
if ($watchList->getDomains()->count() > (int) $this->getParameter('limit_max_watchlist_domains')) {
$this->logger->notice('User {username} tried to create a Watchlist. The maximum number of domains has been reached.', [
'username' => $user->getUserIdentifier(),
]);
throw new AccessDeniedHttpException('You have exceeded the maximum number of domain names allowed in this Watchlist');
@@ -78,7 +91,7 @@ class WatchListController extends AbstractController
$userWatchLists = $user->getWatchLists();
if ($userWatchLists->count() >= (int) $this->getParameter('limit_max_watchlist')) {
$this->logger->notice('User {username} tried to create a Watchlist. However, the maximum number of Watchlists has been reached.', [
$this->logger->notice('User {username} tried to create a Watchlist. The maximum number of Watchlists has been reached', [
'username' => $user->getUserIdentifier(),
]);
throw new AccessDeniedHttpException('You have exceeded the maximum number of Watchlists allowed');
@@ -90,17 +103,29 @@ class WatchListController extends AbstractController
/** @var Domain $domain */
foreach ($watchList->getDomains()->getIterator() as $domain) {
if (in_array($domain, $trackedDomains)) {
$this->logger->notice('User {username} tried to create a watchlist with domain name {ldhName}. However, it is forbidden to register the same domain name twice with limited mode.', [
$ldhName = $domain->getLdhName();
$this->logger->notice('User {username} tried to create a watchlist with domain name {ldhName}. It is forbidden to register the same domain name twice with limited mode', [
'username' => $user->getUserIdentifier(),
'ldhName' => $domain->getLdhName(),
'ldhName' => $ldhName,
]);
throw new AccessDeniedHttpException('It is forbidden to register the same domain name twice in your watchlists with limited mode.');
throw new AccessDeniedHttpException("It is forbidden to register the same domain name twice in your watchlists with limited mode ($ldhName)");
}
}
if (null !== $watchList->getWebhookDsn() && count($watchList->getWebhookDsn()) > (int) $this->getParameter('limit_max_watchlist_webhooks')) {
$this->logger->notice('User {username} tried to create a Watchlist. The maximum number of webhooks has been reached.', [
'username' => $user->getUserIdentifier(),
]);
throw new AccessDeniedHttpException('You have exceeded the maximum number of webhooks allowed in this Watchlist');
}
}
$this->logger->info('User {username} register a Watchlist ({token}).', [
$this->chatNotificationService->sendChatNotification($watchList, new TestChatNotification());
$this->verifyConnector($watchList, $watchList->getConnector());
$this->logger->info('User {username} registers a Watchlist ({token}).', [
'username' => $user->getUserIdentifier(),
'token' => $watchList->getToken(),
]);
@@ -111,6 +136,138 @@ class WatchListController extends AbstractController
return $watchList;
}
#[Route(
path: '/api/watchlists',
name: 'watchlist_get_all_mine',
defaults: [
'_api_resource_class' => WatchList::class,
'_api_operation_name' => 'get_all_mine',
],
methods: ['GET']
)]
public function getWatchLists(): Collection
{
/** @var User $user */
$user = $this->getUser();
return $user->getWatchLists();
}
/**
* @throws \Exception
*/
private function verifyConnector(WatchList $watchList, ?Connector $connector): void
{
/** @var User $user */
$user = $this->getUser();
if (null === $connector) {
return;
}
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');
}
/** @var Domain $domain */
foreach ($watchList->getDomains()->getIterator() as $domain) {
if ($domain->getDeleted()) {
$ldhName = $domain->getLdhName();
throw new BadRequestHttpException("To add a connector, no domain in this Watchlist must have already expired ($ldhName)");
}
}
$connectorProviderClass = $connector->getProvider()->getConnectorProvider();
/** @var AbstractProvider $connectorProvider */
$connectorProvider = new $connectorProviderClass($connector->getAuthData(), $this->httpClient, $this->cacheItemPool, $this->kernel);
$connectorProvider::verifyAuthData($connector->getAuthData(), $this->httpClient); // We want to check if the tokens are OK
$supported = $connectorProvider->isSupported(...$watchList->getDomains()->toArray());
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
*/
#[Route(
path: '/api/watchlists/{token}',
name: 'watchlist_update',
defaults: [
'_api_resource_class' => WatchList::class,
'_api_operation_name' => 'update',
],
methods: ['PUT']
)]
public function putWatchList(WatchList $watchList): WatchList
{
/** @var User $user */
$user = $this->getUser();
$watchList->setUser($user);
if ($this->getParameter('limited_features')) {
if ($watchList->getDomains()->count() > (int) $this->getParameter('limit_max_watchlist_domains')) {
$this->logger->notice('User {username} tried to update a Watchlist. The maximum number of domains has been reached for this Watchlist', [
'username' => $user->getUserIdentifier(),
]);
throw new AccessDeniedHttpException('You have exceeded the maximum number of domain names allowed in this Watchlist');
}
$userWatchLists = $user->getWatchLists();
/** @var Domain[] $trackedDomains */
$trackedDomains = $userWatchLists
->filter(fn (WatchList $wl) => $wl->getToken() !== $watchList->getToken())
->reduce(fn (array $acc, WatchList $wl) => [...$acc, ...$wl->getDomains()->toArray()], []);
/** @var Domain $domain */
foreach ($watchList->getDomains()->getIterator() as $domain) {
if (in_array($domain, $trackedDomains)) {
$ldhName = $domain->getLdhName();
$this->logger->notice('User {username} tried to update a watchlist with domain name {ldhName}. It is forbidden to register the same domain name twice with limited mode', [
'username' => $user->getUserIdentifier(),
'ldhName' => $ldhName,
]);
throw new AccessDeniedHttpException("It is forbidden to register the same domain name twice in your watchlists with limited mode ($ldhName)");
}
}
if (null !== $watchList->getWebhookDsn() && count($watchList->getWebhookDsn()) > (int) $this->getParameter('limit_max_watchlist_webhooks')) {
$this->logger->notice('User {username} tried to update a Watchlist. The maximum number of webhooks has been reached.', [
'username' => $user->getUserIdentifier(),
]);
throw new AccessDeniedHttpException('You have exceeded the maximum number of webhooks allowed in this Watchlist');
}
}
$this->chatNotificationService->sendChatNotification($watchList, new TestChatNotification());
$this->verifyConnector($watchList, $watchList->getConnector());
$this->logger->info('User {username} updates a Watchlist ({token}).', [
'username' => $user->getUserIdentifier(),
'token' => $watchList->getToken(),
]);
$this->em->remove($this->em->getReference(WatchList::class, $watchList->getToken()));
$this->em->flush();
$this->em->persist($watchList);
$this->em->flush();
return $watchList;
}
/**
* @throws ParseException
* @throws EofException
@@ -149,7 +306,7 @@ class WatchListController extends AbstractController
}
/** @var DomainEvent $event */
foreach ($domain->getEvents()->toArray() as $event) {
foreach ($domain->getEvents()->filter(fn (DomainEvent $e) => $e->getDate()->diff(new \DateTimeImmutable('now'))->y <= 10)->getIterator() as $event) {
$calendar->addEvent((new Event())
->setLastModified(new Timestamp($domain->getUpdatedAt()))
->setStatus(EventStatus::CONFIRMED())
@@ -172,20 +329,55 @@ class WatchListController extends AbstractController
]);
}
/**
* @throws \Exception
*/
#[Route(
path: '/api/watchlists',
name: 'watchlist_get_all_mine',
path: '/api/tracked',
name: 'watchlist_get_tracked_domains',
defaults: [
'_api_resource_class' => WatchList::class,
'_api_operation_name' => 'get_all_mine',
],
methods: ['GET']
'_api_operation_name' => 'get_tracked_domains',
]
)]
public function getWatchLists(): Collection
public function getTrackedDomains(): array
{
/** @var User $user */
$user = $this->getUser();
return $user->getWatchLists();
$domains = [];
/** @var WatchList $watchList */
foreach ($user->getWatchLists()->getIterator() as $watchList) {
/** @var Domain $domain */
foreach ($watchList->getDomains()->getIterator() as $domain) {
/** @var DomainEvent|null $exp */
$exp = $domain->getEvents()->findFirst(fn (int $key, DomainEvent $e) => !$e->getDeleted() && 'expiration' === $e->getAction());
if (!$domain->getDeleted()
&& null !== $exp && $exp->getDate() > new \DateTimeImmutable()
&& count(array_filter($domain->getEvents()->toArray(), fn (DomainEvent $e) => !$e->getDeleted() && 'expiration' === $e->getAction())) > 0
&& !in_array($domain, $domains)) {
$domains[] = $domain;
}
}
}
usort($domains, function (Domain $d1, Domain $d2) {
$IMPORTANT_STATUS = ['pending delete', 'redemption period', 'auto renew period'];
/** @var \DateTimeImmutable $exp1 */
$exp1 = $d1->getEvents()->findFirst(fn (int $key, DomainEvent $e) => !$e->getDeleted() && 'expiration' === $e->getAction())->getDate();
/** @var \DateTimeImmutable $exp2 */
$exp2 = $d2->getEvents()->findFirst(fn (int $key, DomainEvent $e) => !$e->getDeleted() && 'expiration' === $e->getAction())->getDate();
$impStatus1 = count(array_intersect($IMPORTANT_STATUS, $d1->getStatus())) > 0;
$impStatus2 = count(array_intersect($IMPORTANT_STATUS, $d2->getStatus())) > 0;
return $impStatus1 && !$impStatus2 ? -1 : (
!$impStatus1 && $impStatus2 ? 2 :
$exp1 <=> $exp2
);
});
return $domains;
}
}

View File

@@ -32,7 +32,9 @@ use Symfony\Component\Uid\Uuid;
normalizationContext: ['groups' => ['connector:create', 'connector:list']], denormalizationContext: ['groups' => 'connector:create'],
name: 'create'
),
new Delete(),
new Delete(
security: 'object.user == user'
),
]
)]
#[ORM\Entity(repositoryClass: ConnectorRepository::class)]

View File

@@ -57,7 +57,7 @@ class Domain
* @var Collection<int, DomainEvent>
*/
#[ORM\OneToMany(targetEntity: DomainEvent::class, mappedBy: 'domain', cascade: ['persist'], orphanRemoval: true)]
#[Groups(['domain:item'])]
#[Groups(['domain:item', 'domain:list'])]
private Collection $events;
/**
@@ -69,7 +69,7 @@ class Domain
private Collection $domainEntities;
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
#[Groups(['domain:item'])]
#[Groups(['domain:item', 'domain:list'])]
private array $status = [];
/**
@@ -90,19 +90,20 @@ class Domain
private Collection $nameservers;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
private ?\DateTimeImmutable $createdAt = null;
private ?\DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
#[Groups(['domain:item', 'domain:list'])]
private ?\DateTimeImmutable $updatedAt;
#[ORM\ManyToOne]
#[ORM\JoinColumn(referencedColumnName: 'tld', nullable: false)]
#[Groups(['domain:item'])]
#[Groups(['domain:item', 'domain:list'])]
private ?Tld $tld = null;
#[ORM\Column]
#[Groups(['domain:item'])]
private ?bool $deleted = null;
#[ORM\Column(nullable: false)]
#[Groups(['domain:item', 'domain:list'])]
private ?bool $deleted;
public function __construct()
{

View File

@@ -27,13 +27,13 @@ class DomainEntity
#[Groups(['domain-entity:entity', 'domain-entity:domain'])]
private array $roles = [];
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
#[ORM\Column]
#[Groups(['domain-entity:entity', 'domain-entity:domain'])]
private ?\DateTimeImmutable $updatedAt = null;
private ?bool $deleted;
public function __construct()
{
$this->updatedAt = new \DateTimeImmutable('now');
$this->deleted = false;
}
public function getDomain(): ?Domain
@@ -75,22 +75,15 @@ class DomainEntity
return $this;
}
public function getUpdatedAt(): ?\DateTimeImmutable
public function getDeleted(): ?bool
{
return $this->updatedAt;
return $this->deleted;
}
public function setUpdatedAt(\DateTimeImmutable $updatedAt): static
public function setDeleted(?bool $deleted): static
{
$this->updatedAt = $updatedAt;
$this->deleted = $deleted;
return $this;
}
#[ORM\PrePersist]
#[ORM\PreUpdate]
public function updateTimestamps(): void
{
$this->setUpdatedAt(new \DateTimeImmutable('now'));
}
}

View File

@@ -8,7 +8,7 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: DomainEventRepository::class)]
class DomainEvent extends Event
{
#[ORM\ManyToOne(targetEntity: Domain::class, cascade: ['persist'], inversedBy: 'events')]
#[ORM\ManyToOne(targetEntity: Domain::class, inversedBy: 'events')]
#[ORM\JoinColumn(referencedColumnName: 'ldh_name', nullable: false)]
private ?Domain $domain = null;

View File

@@ -21,6 +21,15 @@ class Event
#[Groups(['event:list'])]
private ?\DateTimeImmutable $date = null;
#[ORM\Column]
#[Groups(['event:list'])]
private ?bool $deleted;
public function __construct()
{
$this->deleted = false;
}
public function getId(): ?int
{
return $this->id;
@@ -49,4 +58,16 @@ class Event
return $this;
}
public function getDeleted(): ?bool
{
return $this->deleted;
}
public function setDeleted(?bool $deleted): static
{
$this->deleted = $deleted;
return $this;
}
}

111
src/Entity/Statistics.php Normal file
View File

@@ -0,0 +1,111 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Controller\StatisticsController;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/stats',
controller: StatisticsController::class,
shortName: 'Statistics',
read: false,
),
]
)]
class Statistics
{
private ?int $rdapQueries = null;
private ?int $alertSent = null;
private ?int $domainPurchased = null;
private ?int $domainPurchaseFailed = null;
private ?array $domainCount = null;
private ?int $domainTracked = null;
private ?int $domainCountTotal = null;
public function getRdapQueries(): ?int
{
return $this->rdapQueries;
}
public function setRdapQueries(?int $rdapQueries): static
{
$this->rdapQueries = $rdapQueries;
return $this;
}
public function getAlertSent(): ?int
{
return $this->alertSent;
}
public function setAlertSent(?int $alertSent): static
{
$this->alertSent = $alertSent;
return $this;
}
public function getDomainPurchased(): ?int
{
return $this->domainPurchased;
}
public function setDomainPurchased(?int $domainPurchased): static
{
$this->domainPurchased = $domainPurchased;
return $this;
}
public function getDomainCount(): ?array
{
return $this->domainCount;
}
public function setDomainCount(?array $domainCount): static
{
$this->domainCount = $domainCount;
return $this;
}
public function getDomainCountTotal(): ?int
{
return $this->domainCountTotal;
}
public function setDomainCountTotal(?int $domainCountTotal): void
{
$this->domainCountTotal = $domainCountTotal;
}
public function getDomainPurchaseFailed(): ?int
{
return $this->domainPurchaseFailed;
}
public function setDomainPurchaseFailed(?int $domainPurchaseFailed): static
{
$this->domainPurchaseFailed = $domainPurchaseFailed;
return $this;
}
public function getDomainTracked(): ?int
{
return $this->domainTracked;
}
public function setDomainTracked(?int $domainTracked): static
{
$this->domainTracked = $domainTracked;
return $this;
}
}

View File

@@ -6,12 +6,12 @@ use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Controller\WatchListController;
use ApiPlatform\Metadata\Put;
use App\Repository\WatchListRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
@@ -26,13 +26,31 @@ use Symfony\Component\Uid\Uuid;
normalizationContext: ['groups' => 'watchlist:list'],
name: 'get_all_mine',
),
new GetCollection(
uriTemplate: '/tracked',
routeName: 'watchlist_get_tracked_domains',
normalizationContext: ['groups' => [
'domain:list',
'tld:list',
'event:list',
]],
name: 'get_tracked_domains'
),
new Get(
normalizationContext: ['groups' => 'watchlist:item'],
normalizationContext: ['groups' => [
'watchlist:item',
'domain:item',
'event:list',
'domain-entity:entity',
'nameserver-entity:nameserver',
'nameserver-entity:entity',
'tld:item',
],
],
security: 'object.user == user'
),
new Get(
routeName: 'watchlist_calendar',
controller: WatchListController::class,
openapiContext: [
'responses' => [
'200' => [
@@ -58,24 +76,27 @@ use Symfony\Component\Uid\Uuid;
denormalizationContext: ['groups' => 'watchlist:create'],
name: 'create'
),
new Patch(
new Put(
routeName: 'watchlist_update',
normalizationContext: ['groups' => 'watchlist:item'],
denormalizationContext: ['groups' => 'watchlist:update']
denormalizationContext: ['groups' => ['watchlist:create', 'watchlist:token']],
security: 'object.user == user',
name: 'update'
),
new Delete(
security: 'object.user == user'
),
new Delete(),
],
)]
class WatchList
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
#[Groups(['watchlist:item', 'watchlist:list'])]
private string $token;
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'watchLists')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
public ?User $user = null;
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
#[Groups(['watchlist:item', 'watchlist:list', 'watchlist:token'])]
private string $token;
/**
* @var Collection<int, Domain>
*/
@@ -83,29 +104,34 @@ class WatchList
#[ORM\JoinTable(name: 'watch_lists_domains',
joinColumns: [new ORM\JoinColumn(name: 'watch_list_token', referencedColumnName: 'token', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'domain_ldh_name', referencedColumnName: 'ldh_name', onDelete: 'CASCADE')])]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
private Collection $domains;
/**
* @var Collection<int, WatchListTrigger>
*/
#[ORM\OneToMany(targetEntity: WatchListTrigger::class, mappedBy: 'watchList', cascade: ['persist'], orphanRemoval: true)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
#[SerializedName('triggers')]
private Collection $watchListTriggers;
#[ORM\ManyToOne(inversedBy: 'watchLists')]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
private ?Connector $connector = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
private ?string $name = null;
#[ORM\Column]
#[Groups(['watchlist:list', 'watchlist:item'])]
private ?\DateTimeImmutable $createdAt = null;
#[SerializedName('dsn')]
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
private ?array $webhookDsn = null;
public function __construct()
{
$this->token = Uuid::v4();
@@ -119,6 +145,11 @@ class WatchList
return $this->token;
}
public function setToken(string $token): void
{
$this->token = $token;
}
public function getUser(): ?User
{
return $this->user;
@@ -220,4 +251,16 @@ class WatchList
return $this;
}
public function getWebhookDsn(): ?array
{
return $this->webhookDsn;
}
public function setWebhookDsn(?array $webhookDsn): static
{
$this->webhookDsn = $webhookDsn;
return $this;
}
}

View File

@@ -12,17 +12,17 @@ class WatchListTrigger
{
#[ORM\Id]
#[ORM\Column(length: 255)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
private ?string $event = null;
#[ORM\Id]
#[ORM\ManyToOne(targetEntity: WatchList::class, inversedBy: 'watchListTriggers')]
#[ORM\ManyToOne(targetEntity: WatchList::class, cascade: ['persist'], inversedBy: 'watchListTriggers')]
#[ORM\JoinColumn(referencedColumnName: 'token', nullable: false, onDelete: 'CASCADE')]
private ?WatchList $watchList = null;
#[ORM\Id]
#[ORM\Column(enumType: TriggerAction::class)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
private ?TriggerAction $action = null;
public function getEvent(): ?string

View File

@@ -2,7 +2,7 @@
namespace App\Message;
final class ProcessDomainTrigger
final class OrderDomain
{
public function __construct(
public string $watchListToken,

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Message;
final class SendDomainEventNotif
{
public function __construct(
public string $watchListToken,
public string $ldhName,
public \DateTimeImmutable $updatedAt
) {
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Message;
final readonly class ProcessWatchListTrigger
final readonly class UpdateDomainsFromWatchlist
{
public function __construct(
public string $watchListToken,

View File

@@ -0,0 +1,101 @@
<?php
namespace App\MessageHandler;
use App\Entity\Domain;
use App\Entity\WatchList;
use App\Message\OrderDomain;
use App\Notifier\DomainOrderErrorNotification;
use App\Notifier\DomainOrderNotification;
use App\Repository\DomainRepository;
use App\Repository\WatchListRepository;
use App\Service\ChatNotificationService;
use App\Service\Connector\AbstractProvider;
use App\Service\StatService;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Address;
use Symfony\Component\Notifier\Recipient\Recipient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[AsMessageHandler]
final readonly class OrderDomainHandler
{
private Address $sender;
public function __construct(
string $mailerSenderEmail,
string $mailerSenderName,
private WatchListRepository $watchListRepository,
private DomainRepository $domainRepository,
private KernelInterface $kernel,
private HttpClientInterface $client,
private CacheItemPoolInterface $cacheItemPool,
private MailerInterface $mailer,
private LoggerInterface $logger,
private StatService $statService,
private ChatNotificationService $chatNotificationService,
#[Autowire(service: 'service_container')]
private ContainerInterface $locator
) {
$this->sender = new Address($mailerSenderEmail, $mailerSenderName);
}
/**
* @throws TransportExceptionInterface
* @throws \Symfony\Component\Notifier\Exception\TransportExceptionInterface
* @throws \Throwable
*/
public function __invoke(OrderDomain $message): void
{
/** @var WatchList $watchList */
$watchList = $this->watchListRepository->findOneBy(['token' => $message->watchListToken]);
/** @var Domain $domain */
$domain = $this->domainRepository->findOneBy(['ldhName' => $message->ldhName]);
$connector = $watchList->getConnector();
if (null !== $connector && $domain->getDeleted()) {
$this->logger->notice('Watchlist {watchlist} is linked to connector {connector}. A purchase attempt will be made for domain name {ldhName} with provider {provider}.', [
'watchlist' => $message->watchListToken,
'connector' => $connector->getId(),
'ldhName' => $message->ldhName,
'provider' => $connector->getProvider()->value,
]);
try {
$provider = $connector->getProvider();
if (null === $provider) {
throw new \InvalidArgumentException('Provider not found');
}
$connectorProviderClass = $provider->getConnectorProvider();
/** @var AbstractProvider $connectorProvider */
$connectorProvider = $this->locator->get($connectorProviderClass);
$connectorProvider->authenticate($connector->getAuthData());
$connectorProvider->orderDomain($domain, $this->kernel->isDebug());
$this->statService->incrementStat('stats.domain.purchased');
$notification = (new DomainOrderNotification($this->sender, $domain, $connector));
$this->mailer->send($notification->asEmailMessage(new Recipient($watchList->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchList, $notification);
} catch (\Throwable $exception) {
$this->logger->warning('Unable to complete purchase. An error message is sent to user {username}.', [
'username' => $watchList->getUser()->getUserIdentifier(),
]);
$notification = (new DomainOrderErrorNotification($this->sender, $domain));
$this->mailer->send($notification->asEmailMessage(new Recipient($watchList->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchList, $notification);
$this->statService->incrementStat('stats.domain.purchase.failed');
throw $exception;
}
}
}
}

View File

@@ -1,164 +0,0 @@
<?php
namespace App\MessageHandler;
use App\Config\TriggerAction;
use App\Entity\Connector;
use App\Entity\Domain;
use App\Entity\DomainEvent;
use App\Entity\User;
use App\Entity\WatchList;
use App\Entity\WatchListTrigger;
use App\Message\ProcessDomainTrigger;
use App\Repository\DomainRepository;
use App\Repository\WatchListRepository;
use App\Service\Connector\ConnectorInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[AsMessageHandler]
final readonly class ProcessDomainTriggerHandler
{
public function __construct(
private string $mailerSenderEmail,
private string $mailerSenderName,
private MailerInterface $mailer,
private WatchListRepository $watchListRepository,
private DomainRepository $domainRepository,
private KernelInterface $kernel,
private LoggerInterface $logger,
private HttpClientInterface $client,
#[Autowire(service: 'service_container')]
private ContainerInterface $locator
) {
}
/**
* @throws TransportExceptionInterface
* @throws \Exception
*/
public function __invoke(ProcessDomainTrigger $message): void
{
/** @var WatchList $watchList */
$watchList = $this->watchListRepository->findOneBy(['token' => $message->watchListToken]);
/** @var Domain $domain */
$domain = $this->domainRepository->findOneBy(['ldhName' => $message->ldhName]);
$connector = $watchList->getConnector();
if (null !== $connector && $domain->getDeleted()) {
$this->logger->notice('Watchlist {watchlist} is linked to connector {connector}. A purchase attempt will be made for domain name {ldhName} with provider {provider}.', [
'watchlist' => $message->watchListToken,
'connector' => $connector->getId(),
'ldhName' => $message->ldhName,
'provider' => $connector->getProvider()->value,
]);
try {
$provider = $connector->getProvider();
if (null === $provider) {
throw new \Exception('Provider not found');
}
$connectorProviderClass = $provider->getConnectorProvider();
/** @var ConnectorInterface $connectorProvider */
$connectorProvider = $this->locator->get($connectorProviderClass);
$connectorProvider->authenticate($connector->getAuthData());
$connectorProvider->orderDomain($domain, /* $this->kernel->isDebug() */ false);
$this->sendEmailDomainOrdered($domain, $connector, $watchList->getUser());
} catch (\Throwable $t) {
dump($t);
$this->logger->error('Unable to complete purchase. An error message is sent to user {username}.', [
'username' => $watchList->getUser()->getUserIdentifier(),
'error' => $t,
]);
$this->sendEmailDomainOrderError($domain, $watchList->getUser());
}
}
/** @var DomainEvent $event */
foreach ($domain->getEvents()->filter(fn ($event) => $message->updatedAt < $event->getDate() && $event->getDate() < new \DateTime()) as $event) {
$watchListTriggers = $watchList->getWatchListTriggers()
->filter(fn ($trigger) => $trigger->getEvent() === $event->getAction());
/** @var WatchListTrigger $watchListTrigger */
foreach ($watchListTriggers->getIterator() as $watchListTrigger) {
$this->logger->info('Action {event} has been detected on the domain name {ldhName}. A notification is sent to user {username}.', [
'event' => $event->getAction(),
'ldhName' => $message->ldhName,
'username' => $watchList->getUser()->getUserIdentifier(),
]);
if (TriggerAction::SendEmail == $watchListTrigger->getAction()) {
$this->sendEmailDomainUpdated($event, $watchList->getUser());
}
}
}
}
/**
* @throws TransportExceptionInterface
*/
private function sendEmailDomainOrdered(Domain $domain, Connector $connector, User $user): void
{
$email = (new TemplatedEmail())
->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail())
->priority(Email::PRIORITY_HIGHEST)
->subject('A domain name has been ordered')
->htmlTemplate('emails/success/domain_ordered.html.twig')
->locale('en')
->context([
'domain' => $domain,
'provider' => $connector->getProvider()->value,
]);
$this->mailer->send($email);
}
/**
* @throws TransportExceptionInterface
*/
private function sendEmailDomainOrderError(Domain $domain, User $user): void
{
$email = (new TemplatedEmail())
->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail())
->subject('An error occurred while ordering a domain name')
->htmlTemplate('emails/errors/domain_order.html.twig')
->locale('en')
->context([
'domain' => $domain,
]);
$this->mailer->send($email);
}
/**
* @throws TransportExceptionInterface
*/
private function sendEmailDomainUpdated(DomainEvent $domainEvent, User $user): void
{
$email = (new TemplatedEmail())
->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail())
->priority(Email::PRIORITY_HIGHEST)
->subject('A domain name has been changed')
->htmlTemplate('emails/success/domain_updated.html.twig')
->locale('en')
->context([
'event' => $domainEvent,
]);
$this->mailer->send($email);
}
}

View File

@@ -1,90 +0,0 @@
<?php
namespace App\MessageHandler;
use App\Entity\Domain;
use App\Entity\User;
use App\Entity\WatchList;
use App\Message\ProcessDomainTrigger;
use App\Message\ProcessWatchListTrigger;
use App\Repository\WatchListRepository;
use App\Service\RDAPService;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mime\Address;
#[AsMessageHandler]
final readonly class ProcessWatchListTriggerHandler
{
public function __construct(
private RDAPService $RDAPService,
private MailerInterface $mailer,
private string $mailerSenderEmail,
private string $mailerSenderName,
private MessageBusInterface $bus,
private WatchListRepository $watchListRepository,
private LoggerInterface $logger
) {
}
/**
* @throws TransportExceptionInterface
* @throws \Exception
* @throws ExceptionInterface
*/
public function __invoke(ProcessWatchListTrigger $message): void
{
/** @var WatchList $watchList */
$watchList = $this->watchListRepository->findOneBy(['token' => $message->watchListToken]);
$this->logger->info('Domain names from Watchlist {token} will be processed.', [
'token' => $message->watchListToken,
]);
/** @var Domain $domain */
foreach ($watchList->getDomains()
->filter(fn ($domain) => $domain->getUpdatedAt()
->diff(
new \DateTimeImmutable('now'))->days >= 7
|| $this->RDAPService::isToBeWatchClosely($domain, $domain->getUpdatedAt())
) as $domain
) {
$updatedAt = $domain->getUpdatedAt();
try {
$this->RDAPService->registerDomain($domain->getLdhName());
} catch (\Throwable $e) {
$this->logger->error('An update error email is sent to user {username}.', [
'username' => $watchList->getUser()->getUserIdentifier(),
'error' => $e,
]);
$this->sendEmailDomainUpdateError($domain, $watchList->getUser());
}
$this->bus->dispatch(new ProcessDomainTrigger($watchList->getToken(), $domain->getLdhName(), $updatedAt));
}
}
/**
* @throws TransportExceptionInterface
*/
private function sendEmailDomainUpdateError(Domain $domain, User $user): void
{
$email = (new TemplatedEmail())
->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail())
->subject('An error occurred while updating a domain name')
->htmlTemplate('emails/errors/domain_update.html.twig')
->locale('en')
->context([
'domain' => $domain,
]);
$this->mailer->send($email);
}
}

View File

@@ -4,7 +4,7 @@ namespace App\MessageHandler;
use App\Entity\WatchList;
use App\Message\ProcessWatchListsTrigger;
use App\Message\ProcessWatchListTrigger;
use App\Message\UpdateDomainsFromWatchlist;
use App\Repository\WatchListRepository;
use Random\Randomizer;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
@@ -30,7 +30,7 @@ final readonly class ProcessWatchListsTriggerHandler
/** @var WatchList $watchList */
foreach ($watchLists as $watchList) {
$this->bus->dispatch(new ProcessWatchListTrigger($watchList->getToken()));
$this->bus->dispatch(new UpdateDomainsFromWatchlist($watchList->getToken()));
}
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\MessageHandler;
use App\Config\TriggerAction;
use App\Entity\Domain;
use App\Entity\DomainEvent;
use App\Entity\WatchList;
use App\Entity\WatchListTrigger;
use App\Message\SendDomainEventNotif;
use App\Notifier\DomainUpdateNotification;
use App\Repository\DomainRepository;
use App\Repository\WatchListRepository;
use App\Service\ChatNotificationService;
use App\Service\StatService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Notifier\Recipient\Recipient;
#[AsMessageHandler]
final readonly class SendDomainEventNotifHandler
{
private Address $sender;
public function __construct(
string $mailerSenderEmail,
string $mailerSenderName,
private LoggerInterface $logger,
private MailerInterface $mailer,
private StatService $statService,
private DomainRepository $domainRepository,
private WatchListRepository $watchListRepository,
private ChatNotificationService $chatNotificationService
) {
$this->sender = new Address($mailerSenderEmail, $mailerSenderName);
}
/**
* @throws TransportExceptionInterface
* @throws \Exception
* @throws ExceptionInterface
*/
public function __invoke(SendDomainEventNotif $message): void
{
/** @var WatchList $watchList */
$watchList = $this->watchListRepository->findOneBy(['token' => $message->watchListToken]);
/** @var Domain $domain */
$domain = $this->domainRepository->findOneBy(['ldhName' => $message->ldhName]);
/** @var DomainEvent $event */
foreach ($domain->getEvents()->filter(fn ($event) => $message->updatedAt < $event->getDate() && $event->getDate() < new \DateTime()) as $event) {
$watchListTriggers = $watchList->getWatchListTriggers()
->filter(fn ($trigger) => $trigger->getEvent() === $event->getAction());
/** @var WatchListTrigger $watchListTrigger */
foreach ($watchListTriggers->getIterator() as $watchListTrigger) {
$this->logger->info('Action {event} has been detected on the domain name {ldhName}. A notification is sent to user {username}.', [
'event' => $event->getAction(),
'ldhName' => $message->ldhName,
'username' => $watchList->getUser()->getUserIdentifier(),
]);
$recipient = new Recipient($watchList->getUser()->getEmail());
$notification = new DomainUpdateNotification($this->sender, $event);
if (TriggerAction::SendEmail == $watchListTrigger->getAction()) {
$this->mailer->send($notification->asEmailMessage($recipient)->getMessage());
} elseif (TriggerAction::SendChat == $watchListTrigger->getAction()) {
$this->chatNotificationService->sendChatNotification($watchList, $notification);
}
$this->statService->incrementStat('stats.alert.sent');
}
}
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\MessageHandler;
use App\Entity\Domain;
use App\Entity\WatchList;
use App\Message\OrderDomain;
use App\Message\SendDomainEventNotif;
use App\Message\UpdateDomainsFromWatchlist;
use App\Notifier\DomainUpdateErrorNotification;
use App\Repository\WatchListRepository;
use App\Service\RDAPService;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Notifier\Recipient\Recipient;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
#[AsMessageHandler]
final readonly class UpdateDomainsFromWatchlistHandler
{
private Address $sender;
public function __construct(
private RDAPService $RDAPService,
private MailerInterface $mailer,
string $mailerSenderEmail,
string $mailerSenderName,
private MessageBusInterface $bus,
private WatchListRepository $watchListRepository,
private LoggerInterface $logger
) {
$this->sender = new Address($mailerSenderEmail, $mailerSenderName);
}
/**
* @throws ExceptionInterface
* @throws TransportExceptionInterface
* @throws ClientExceptionInterface
* @throws DecodingExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ServerExceptionInterface
* @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
* @throws \Throwable
*/
public function __invoke(UpdateDomainsFromWatchlist $message): void
{
/** @var WatchList $watchList */
$watchList = $this->watchListRepository->findOneBy(['token' => $message->watchListToken]);
$this->logger->info('Domain names from Watchlist {token} will be processed.', [
'token' => $message->watchListToken,
]);
/** @var Domain $domain */
foreach ($watchList->getDomains()
->filter(fn ($domain) => $domain->getUpdatedAt()
->diff(new \DateTimeImmutable())->days >= 7
|| (
($domain->getUpdatedAt()
->diff(new \DateTimeImmutable())->h * 60 + $domain->getUpdatedAt()
->diff(new \DateTimeImmutable())->i) >= 50
&& $this->RDAPService::isToBeWatchClosely($domain)
)
|| (count(array_intersect($domain->getStatus(), ['auto renew period', 'client hold', 'server hold'])) > 0
&& $domain->getUpdatedAt()->diff(new \DateTimeImmutable())->days >= 1
)
) as $domain
) {
$updatedAt = $domain->getUpdatedAt();
try {
$this->RDAPService->registerDomain($domain->getLdhName());
$this->bus->dispatch(new SendDomainEventNotif($watchList->getToken(), $domain->getLdhName(), $updatedAt));
} catch (NotFoundHttpException) {
if (null !== $watchList->getConnector()) {
$this->bus->dispatch(new OrderDomain($watchList->getToken(), $domain->getLdhName(), $updatedAt));
}
} catch (\Throwable $e) {
$this->logger->error('An update error email is sent to user {username}.', [
'username' => $watchList->getUser()->getUserIdentifier(),
'error' => $e,
]);
$email = (new DomainUpdateErrorNotification($this->sender, $domain))
->asEmailMessage(new Recipient($watchList->getUser()->getEmail()));
$this->mailer->send($email->getMessage());
throw $e;
}
}
}
}

View File

@@ -4,6 +4,7 @@ namespace App\MessageHandler;
use App\Message\UpdateRdapServers;
use App\Service\RDAPService;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
@@ -14,8 +15,10 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
#[AsMessageHandler]
final readonly class UpdateRdapServersHandler
{
public function __construct(private RDAPService $RDAPService)
{
public function __construct(
private RDAPService $RDAPService,
private ParameterBagInterface $bag
) {
}
/**
@@ -29,17 +32,32 @@ final readonly class UpdateRdapServersHandler
{
/** @var \Throwable[] $throws */
$throws = [];
try {
$this->RDAPService->updateTldListIANA();
$this->RDAPService->updateGTldListICANN();
} catch (\Throwable $throwable) {
$throws[] = $throwable;
}
try {
$this->RDAPService->updateRDAPServers();
$this->RDAPService->updateRDAPServersFromIANA();
} catch (\Throwable $throwable) {
$throws[] = $throwable;
}
try {
$this->RDAPService->updateRDAPServersFromIANA();
} catch (\Throwable $throwable) {
$throws[] = $throwable;
}
try {
$this->RDAPService->updateRDAPServersFromFile($this->bag->get('custom_rdap_servers_file'));
} catch (\Throwable $throwable) {
$throws[] = $throwable;
}
if (!empty($throws)) {
throw $throws[0];
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Notifier;
use App\Config\WebhookScheme;
use App\Entity\Domain;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mime\Address;
use Symfony\Component\Notifier\Message\ChatMessage;
use Symfony\Component\Notifier\Message\EmailMessage;
use Symfony\Component\Notifier\Message\PushMessage;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
use Symfony\Component\Notifier\Recipient\RecipientInterface;
class DomainOrderErrorNotification extends DomainWatchdogNotification
{
public function __construct(
private readonly Address $sender,
private readonly Domain $domain
) {
parent::__construct();
}
public function asChatMessage(?RecipientInterface $recipient = null, ?string $transport = null): ?ChatMessage
{
$webhookScheme = WebhookScheme::from($transport);
$ldhName = $this->domain->getLdhName();
$this->subject("Error: Domain Order $ldhName")
->content("Domain name $ldhName tried to be purchased. The attempt failed.")
->importance(Notification::IMPORTANCE_HIGH);
return ChatMessage::fromNotification($this);
}
public function asPushMessage(?RecipientInterface $recipient = null, ?string $transport = null): ?PushMessage
{
$ldhName = $this->domain->getLdhName();
$this->subject("Error: Domain Order $ldhName")
->content("Domain name $ldhName tried to be purchased. The attempt failed.")
->importance(Notification::IMPORTANCE_HIGH);
return PushMessage::fromNotification($this);
}
public function asEmailMessage(EmailRecipientInterface $recipient): EmailMessage
{
return new EmailMessage((new TemplatedEmail())
->from($this->sender)
->to($recipient->getEmail())
->subject('An error occurred while ordering a domain name')
->htmlTemplate('emails/errors/domain_order.html.twig')
->locale('en')
->context([
'domain' => $this->domain,
]));
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Notifier;
use App\Entity\Connector;
use App\Entity\Domain;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Notifier\Message\ChatMessage;
use Symfony\Component\Notifier\Message\EmailMessage;
use Symfony\Component\Notifier\Message\PushMessage;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
use Symfony\Component\Notifier\Recipient\RecipientInterface;
class DomainOrderNotification extends DomainWatchdogNotification
{
public function __construct(
private readonly Address $sender,
private readonly Domain $domain,
private readonly Connector $connector
) {
parent::__construct();
}
public function asChatMessage(?RecipientInterface $recipient = null, ?string $transport = null): ?ChatMessage
{
$ldhName = $this->domain->getLdhName();
$this
->subject("Success: Domain Ordered $ldhName!")
->content("Domain name $ldhName has just been purchased. The API provider did not return an error.")
->importance(Notification::IMPORTANCE_HIGH);
return ChatMessage::fromNotification($this);
}
public function asPushMessage(?RecipientInterface $recipient = null, ?string $transport = null): ?PushMessage
{
$ldhName = $this->domain->getLdhName();
$this
->subject("Success: Domain Ordered $ldhName!")
->content("Domain name $ldhName has just been purchased. The API provider did not return an error.")
->importance(Notification::IMPORTANCE_HIGH);
return PushMessage::fromNotification($this);
}
public function asEmailMessage(EmailRecipientInterface $recipient): EmailMessage
{
return new EmailMessage((new TemplatedEmail())
->from($this->sender)
->to($recipient->getEmail())
->priority(Email::PRIORITY_HIGHEST)
->subject('A domain name has been ordered')
->htmlTemplate('emails/success/domain_ordered.html.twig')
->locale('en')
->context([
'domain' => $this->domain,
'provider' => $this->connector->getProvider()->value,
]));
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Notifier;
use App\Entity\Domain;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mime\Address;
use Symfony\Component\Notifier\Message\ChatMessage;
use Symfony\Component\Notifier\Message\EmailMessage;
use Symfony\Component\Notifier\Message\PushMessage;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
use Symfony\Component\Notifier\Recipient\RecipientInterface;
class DomainUpdateErrorNotification extends DomainWatchdogNotification
{
public function __construct(
private readonly Address $sender,
private readonly Domain $domain
) {
parent::__construct();
}
public function asChatMessage(?RecipientInterface $recipient = null, ?string $transport = null): ?ChatMessage
{
$ldhName = $this->domain->getLdhName();
$this->subject("Error: Domain Update $ldhName")
->content("Domain name $ldhName tried to be updated. The attempt failed.")
->importance(Notification::IMPORTANCE_MEDIUM);
return ChatMessage::fromNotification($this);
}
public function asPushMessage(?RecipientInterface $recipient = null, ?string $transport = null): ?PushMessage
{
$ldhName = $this->domain->getLdhName();
$this->subject("Error: Domain Update $ldhName")
->content("Domain name $ldhName tried to be updated. The attempt failed.")
->importance(Notification::IMPORTANCE_MEDIUM);
return PushMessage::fromNotification($this);
}
public function asEmailMessage(EmailRecipientInterface $recipient): EmailMessage
{
return new EmailMessage((new TemplatedEmail())
->from($this->sender)
->to($recipient->getEmail())
->subject('An error occurred while updating a domain name')
->htmlTemplate('emails/errors/domain_update.html.twig')
->locale('en')
->context([
'domain' => $this->domain,
]));
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Notifier;
use App\Entity\DomainEvent;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Notifier\Message\ChatMessage;
use Symfony\Component\Notifier\Message\EmailMessage;
use Symfony\Component\Notifier\Message\PushMessage;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\EmailRecipientInterface;
use Symfony\Component\Notifier\Recipient\RecipientInterface;
class DomainUpdateNotification extends DomainWatchdogNotification
{
public function __construct(
private readonly Address $sender,
private readonly DomainEvent $domainEvent
) {
parent::__construct();
}
public function asChatMessage(?RecipientInterface $recipient = null, ?string $transport = null): ?ChatMessage
{
$ldhName = $this->domainEvent->getDomain()->getLdhName();
$action = $this->domainEvent->getAction();
$this->subject("Domain changed $ldhName ($action)")
->content("Domain name $ldhName information has been updated ($action).")
->importance(Notification::IMPORTANCE_HIGH);
return ChatMessage::fromNotification($this);
}
public function asPushMessage(?RecipientInterface $recipient = null, ?string $transport = null): ?PushMessage
{
$ldhName = $this->domainEvent->getDomain()->getLdhName();
$action = $this->domainEvent->getAction();
$this->subject("Domain changed $ldhName ($action)")
->content("Domain name $ldhName information has been updated ($action).")
->importance(Notification::IMPORTANCE_HIGH);
return PushMessage::fromNotification($this);
}
public function asEmailMessage(EmailRecipientInterface $recipient): EmailMessage
{
return new EmailMessage((new TemplatedEmail())
->from($this->sender)
->to($recipient->getEmail())
->priority(Email::PRIORITY_HIGHEST)
->subject('A domain name has been changed')
->htmlTemplate('emails/success/domain_updated.html.twig')
->locale('en')
->context([
'event' => $this->domainEvent,
]));
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Notifier;
use Symfony\Component\Notifier\Notification\ChatNotificationInterface;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Notification\PushNotificationInterface;
abstract class DomainWatchdogNotification extends Notification implements ChatNotificationInterface, PushNotificationInterface
{
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Notifier;
use Symfony\Component\Notifier\Message\ChatMessage;
use Symfony\Component\Notifier\Message\PushMessage;
use Symfony\Component\Notifier\Notification\Notification;
use Symfony\Component\Notifier\Recipient\RecipientInterface;
class TestChatNotification extends DomainWatchdogNotification
{
public function asChatMessage(?RecipientInterface $recipient = null, ?string $transport = null): ?ChatMessage
{
$this
->subject('Test notification')
->content('This is a test message. If you can read me, this Webhook is configured correctly')
->importance(Notification::IMPORTANCE_LOW);
return ChatMessage::fromNotification($this);
}
public function asPushMessage(?RecipientInterface $recipient = null, ?string $transport = null): ?PushMessage
{
$this
->subject('Test notification')
->content('This is a test message. If you can read me, this Webhook is configured correctly')
->importance(Notification::IMPORTANCE_LOW);
return PushMessage::fromNotification($this);
}
}

View File

@@ -21,7 +21,7 @@ final readonly class SendNotifWatchListTriggerSchedule implements ScheduleProvid
{
return (new Schedule())
->add(
RecurringMessage::every('1 day', new ProcessWatchListsTrigger()),
RecurringMessage::every('1 hour', new ProcessWatchListsTrigger()),
)
->stateful($this->cache);
}

View File

@@ -22,7 +22,7 @@ readonly class EmailVerifier
/**
* @throws TransportExceptionInterface
*/
public function sendEmailConfirmation(string $verifyEmailRouteName, User $user, TemplatedEmail $email): void
public function sendEmailConfirmation(string $verifyEmailRouteName, User $user, TemplatedEmail $email): TemplatedEmail
{
$signatureComponents = $this->verifyEmailHelper->generateSignature(
$verifyEmailRouteName,
@@ -39,6 +39,8 @@ readonly class EmailVerifier
$email->context($context);
$this->mailer->send($email);
return $email;
}
public function handleEmailConfirmation(Request $request, User $user): void

View File

@@ -11,6 +11,7 @@ use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface;
@@ -21,6 +22,7 @@ class JWTAuthenticator implements AuthenticationSuccessHandlerInterface
public function __construct(
protected JWTTokenManagerInterface $jwtManager,
protected EventDispatcherInterface $dispatcher,
protected KernelInterface $kernel
) {
}
@@ -43,10 +45,10 @@ class JWTAuthenticator implements AuthenticationSuccessHandlerInterface
new Cookie(
'BEARER',
$jwt,
time() + 7200, // expiration
time() + 604800, // expiration
'/',
null,
false,
!$this->kernel->isDebug(),
true,
false,
'strict'

View File

@@ -75,7 +75,7 @@ class OAuthAuthenticator extends OAuth2Authenticator implements AuthenticationEn
new Cookie(
'BEARER',
$token,
time() + 7200, // expiration
time() + 604800, // expiration
'/',
null,
true,

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Service;
use App\Config\WebhookScheme;
use App\Entity\WatchList;
use App\Notifier\DomainWatchdogNotification;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Notifier\Exception\InvalidArgumentException;
use Symfony\Component\Notifier\Recipient\NoRecipient;
use Symfony\Component\Notifier\Transport\AbstractTransportFactory;
use Symfony\Component\Notifier\Transport\Dsn;
readonly class ChatNotificationService
{
public function __construct(
private LoggerInterface $logger
) {
}
public function sendChatNotification(WatchList $watchList, DomainWatchdogNotification $notification): void
{
$webhookDsn = $watchList->getWebhookDsn();
if (null !== $webhookDsn && 0 !== count($webhookDsn)) {
foreach ($webhookDsn 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();
$push = $notification->asPushMessage(new NoRecipient());
$chat = $notification->asChatMessage(new NoRecipient(), $webhookScheme->value);
try {
$factory = $transportFactory->create($dsn);
if ($factory->supports($push)) {
$factory->send($push);
} elseif ($factory->supports($chat)) {
$factory->send($chat);
} else {
throw new BadRequestHttpException('Unsupported message type');
}
$this->logger->info('Chat message sent with {schema} for Watchlist {token}',
[
'scheme' => $webhookScheme->name,
'token' => $watchList->getToken(),
]);
} catch (\Throwable $exception) {
$this->logger->error('Unable to send a chat message to {scheme} for Watchlist {token}',
[
'scheme' => $webhookScheme->name,
'token' => $watchList->getToken(),
]);
throw new BadRequestHttpException($exception->getMessage());
}
}
}
}
}

View File

@@ -1,13 +0,0 @@
<?php
namespace App\Service\Connector;
abstract class AbstractConnector implements ConnectorInterface
{
protected array $authData;
public function authenticate(array $authData)
{
$this->authData = $authData;
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Service\Connector;
use App\Entity\Domain;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
abstract class AbstractProvider
{
protected array $authData;
public function __construct(
protected CacheItemPoolInterface $cacheItemPool
) {
}
abstract public static function verifyAuthData(array $authData, HttpClientInterface $client): array;
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;
}
public function authenticate(array $authData): void
{
$this->authData = $authData;
}
abstract protected function getCachedTldList(): CacheItemInterface;
abstract protected function getSupportedTldList(): array;
}

View File

@@ -1,15 +0,0 @@
<?php
namespace App\Service\Connector;
use App\Entity\Domain;
use Symfony\Contracts\HttpClient\HttpClientInterface;
interface ConnectorInterface
{
public function authenticate(array $authData);
public function orderDomain(Domain $domain, bool $dryRun): void;
public static function verifyAuthData(array $authData, HttpClientInterface $client): array;
}

View File

@@ -3,18 +3,21 @@
namespace App\Service\Connector;
use App\Entity\Domain;
use http\Exception\InvalidArgumentException;
use Psr\Cache\CacheItemInterface;
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;
class GandiConnector extends AbstractConnector
class GandiProvider extends AbstractProvider
{
private const BASE_URL = 'https://api.gandi.net/v5';
private const BASE_URL = 'https://api.gandi.net';
public function __construct(private HttpClientInterface $client)
{
@@ -30,12 +33,12 @@ class GandiConnector extends AbstractConnector
public function orderDomain(Domain $domain, bool $dryRun = false): void
{
if (!$domain->getDeleted()) {
throw new InvalidArgumentException('The domain name still appears in the WHOIS database');
throw new \InvalidArgumentException('The domain name still appears in the WHOIS database');
}
$ldhName = $domain->getLdhName();
if (!$ldhName) {
throw new InvalidArgumentException('Domain name cannot be null');
throw new \InvalidArgumentException('Domain name cannot be null');
}
$authData = self::verifyAuthData($this->authData, $this->client);
@@ -130,4 +133,32 @@ class GandiConnector extends AbstractConnector
return $authDataReturned;
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
*/
protected function getSupportedTldList(): array
{
$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();
return array_map(fn ($tld) => $tld['name'], $response);
}
/**
* @throws \Psr\Cache\InvalidArgumentException
*/
protected function getCachedTldList(): CacheItemInterface
{
return $this->cacheItemPool->getItem('app.provider.ovh.supported-tld');
}
}

View File

@@ -3,11 +3,12 @@
namespace App\Service\Connector;
use App\Entity\Domain;
use Psr\Cache\CacheItemInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[Autoconfigure(public: true)]
class NamecheapConnector extends AbstractConnector
class NamecheapConnector extends AbstractProvider
{
public const BASE_URL = 'https://api.namecheap.com/xml.response';
@@ -83,4 +84,14 @@ class NamecheapConnector extends AbstractConnector
{
return $authData;
}
protected function getCachedTldList(): CacheItemInterface
{
// TODO: Implement getCachedTldList() method.
}
protected function getSupportedTldList(): array
{
// TODO: Implement getSupportedTldList() method.
}
}

View File

@@ -3,14 +3,22 @@
namespace App\Service\Connector;
use App\Entity\Domain;
use GuzzleHttp\Exception\ClientException;
use Ovh\Api;
use Ovh\Exceptions\InvalidParameterException;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class OvhConnector extends AbstractConnector
class OvhProvider extends AbstractProvider
{
public const REQUIRED_ROUTES = [
[
'method' => 'GET',
'path' => '/domain/extensions',
],
[
'method' => 'GET',
'path' => '/order/cart',
@@ -45,12 +53,12 @@ class OvhConnector extends AbstractConnector
public function orderDomain(Domain $domain, bool $dryRun = false): void
{
if (!$domain->getDeleted()) {
throw new \Exception('The domain name still appears in the WHOIS database');
throw new \InvalidArgumentException('The domain name still appears in the WHOIS database');
}
$ldhName = $domain->getLdhName();
if (!$ldhName) {
throw new \Exception('Domain name cannot be null');
throw new \InvalidArgumentException('Domain name cannot be null');
}
$authData = self::verifyAuthData($this->authData, $this->client);
@@ -87,7 +95,7 @@ class OvhConnector extends AbstractConnector
);
if (empty($offer)) {
$conn->delete("/order/cart/{$cartId}");
throw new \Exception('Cannot buy this domain name');
throw new \InvalidArgumentException('Cannot buy this domain name');
}
$item = $conn->post("/order/cart/{$cartId}/domain", [
@@ -163,14 +171,18 @@ class OvhConnector extends AbstractConnector
$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) {
@@ -186,7 +198,7 @@ class OvhConnector extends AbstractConnector
}
if (!$ok) {
throw new BadRequestHttpException('The credentials provided do not have enough permissions to purchase a domain name.');
throw new BadRequestHttpException('This Connector does not have enough permissions on the Provider API. Please recreate this Connector.');
}
}
@@ -202,4 +214,33 @@ class OvhConnector extends AbstractConnector
'waiveRetractationPeriod' => $waiveRetractationPeriod,
];
}
/**
* @throws InvalidParameterException
* @throws \JsonException
* @throws \Exception
*/
protected function getSupportedTldList(): array
{
$authData = self::verifyAuthData($this->authData, $this->client);
$conn = new Api(
$authData['appKey'],
$authData['appSecret'],
$authData['apiEndpoint'],
$authData['consumerKey']
);
return $conn->get('/domain/extensions', [
'ovhSubsidiary' => $authData['ovhSubsidiary'],
]);
}
/**
* @throws InvalidArgumentException
*/
protected function getCachedTldList(): CacheItemInterface
{
return $this->cacheItemPool->getItem('app.provider.ovh.supported-tld');
}
}

View File

@@ -28,6 +28,7 @@ use Psr\Log\LoggerInterface;
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Yaml\Yaml;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
@@ -73,7 +74,17 @@ readonly class RDAPService
'xn--hlcj6aya9esc7a',
];
public const IMPORTANT_EVENTS = [EventAction::Deletion->value, EventAction::Expiration->value];
private const IMPORTANT_EVENTS = [EventAction::Deletion->value, EventAction::Expiration->value];
private const IMPORTANT_STATUS = [
'redemption period',
'pending delete',
'pending create',
'pending renew',
'pending restore',
'pending transfer',
'pending update',
'add period',
];
public function __construct(private HttpClientInterface $client,
private EntityRepository $entityRepository,
@@ -86,7 +97,8 @@ readonly class RDAPService
private RdapServerRepository $rdapServerRepository,
private TldRepository $tldRepository,
private EntityManagerInterface $em,
private LoggerInterface $logger
private LoggerInterface $logger,
private StatService $statService
) {
}
@@ -96,10 +108,11 @@ readonly class RDAPService
*
* @throws \Exception
*/
public static function isToBeWatchClosely(Domain $domain, \DateTimeImmutable $updatedAt): bool
public static function isToBeWatchClosely(Domain $domain): bool
{
if ($updatedAt->diff(new \DateTimeImmutable('now'))->days < 1) {
return false;
$status = $domain->getStatus();
if ((!empty($status) && count(array_intersect($status, self::IMPORTANT_STATUS))) || $domain->getDeleted()) {
return true;
}
/** @var DomainEvent[] $events */
@@ -162,6 +175,8 @@ readonly class RDAPService
]);
try {
$this->statService->incrementStat('stats.rdap_queries.count');
$res = $this->client->request(
'GET', $rdapServerUrl.'domain/'.$idnDomain
)->toArray();
@@ -213,6 +228,11 @@ readonly class RDAPService
$this->em->persist($domain);
$this->em->flush();
/** @var DomainEvent $event */
foreach ($domain->getEvents()->getIterator() as $event) {
$event->setDeleted(true);
}
foreach ($res['events'] as $rdapEvent) {
if ($rdapEvent['eventAction'] === EventAction::LastUpdateOfRDAPDatabase->value) {
continue;
@@ -229,7 +249,14 @@ readonly class RDAPService
}
$domain->addEvent($event
->setAction($rdapEvent['eventAction'])
->setDate(new \DateTimeImmutable($rdapEvent['eventDate'])));
->setDate(new \DateTimeImmutable($rdapEvent['eventDate']))
->setDeleted(false)
);
}
/** @var DomainEntity $domainEntity */
foreach ($domain->getDomainEntities()->getIterator() as $domainEntity) {
$domainEntity->setDeleted(true);
}
if (array_key_exists('entities', $res) && is_array($res['entities'])) {
@@ -270,8 +297,9 @@ readonly class RDAPService
$domain->addDomainEntity($domainEntity
->setDomain($domain)
->setEntity($entity)
->setRoles($roles))
->updateTimestamps();
->setRoles($roles)
->setDeleted(false)
);
$this->em->persist($domainEntity);
$this->em->flush();
@@ -279,10 +307,18 @@ readonly class RDAPService
}
if (array_key_exists('nameservers', $res) && is_array($res['nameservers'])) {
$domain->getNameservers()->clear();
foreach ($res['nameservers'] as $rdapNameserver) {
$nameserver = $this->nameserverRepository->findOneBy([
'ldhName' => strtolower($rdapNameserver['ldhName']),
]);
$domainNS = $domain->getNameservers()->findFirst(fn (int $key, Nameserver $ns) => $ns->getLdhName() === $rdapNameserver['ldhName']);
if (null !== $domainNS) {
$nameserver = $domainNS;
}
if (null === $nameserver) {
$nameserver = new Nameserver();
}
@@ -349,7 +385,7 @@ readonly class RDAPService
if (false === $lastDotPosition) {
throw new BadRequestException('Domain must contain at least one dot');
}
$tld = strtolower(substr($domain, $lastDotPosition + 1));
$tld = strtolower(idn_to_ascii(substr($domain, $lastDotPosition + 1)));
return $this->tldRepository->findOneBy(['tld' => $tld]);
}
@@ -365,7 +401,7 @@ readonly class RDAPService
if (null === $entity) {
$entity = new Entity();
} else {
$this->logger->info('The entity {handle} was not known to this Domain Watchdog instance.', [
'handle' => $rdapEntity['handle'],
]);
@@ -392,6 +428,14 @@ readonly class RDAPService
return $entity;
}
/** @var EntityEvent $event */
foreach ($entity->getEvents()->getIterator() as $event) {
$event->setDeleted(true);
}
$this->em->persist($entity);
$this->em->flush();
foreach ($rdapEntity['events'] as $rdapEntityEvent) {
$eventAction = $rdapEntityEvent['eventAction'];
if ($eventAction === EventAction::LastChanged->value || $eventAction === EventAction::LastUpdateOfRDAPDatabase->value) {
@@ -403,13 +447,15 @@ readonly class RDAPService
]);
if (null !== $event) {
$event->setDeleted(false);
continue;
}
$entity->addEvent(
(new EntityEvent())
->setEntity($entity)
->setAction($rdapEntityEvent['eventAction'])
->setDate(new \DateTimeImmutable($rdapEntityEvent['eventDate'])));
->setDate(new \DateTimeImmutable($rdapEntityEvent['eventDate']))
->setDeleted(false));
}
return $entity;
@@ -423,14 +469,23 @@ readonly class RDAPService
* @throws ClientExceptionInterface
* @throws ORMException
*/
public function updateRDAPServers(): void
public function updateRDAPServersFromIANA(): void
{
$this->logger->info('Started updating the RDAP server list.');
$this->logger->info('Start of update the RDAP server list from IANA.');
$dnsRoot = $this->client->request(
'GET', 'https://data.iana.org/rdap/dns.json'
)->toArray();
$this->updateRDAPServers($dnsRoot);
}
/**
* @throws ORMException
* @throws \Exception
*/
private function updateRDAPServers(array $dnsRoot): void
{
foreach ($dnsRoot['services'] as $service) {
foreach ($service[0] as $tld) {
if ('' === $tld) {
@@ -442,7 +497,10 @@ readonly class RDAPService
if (null === $server) {
$server = new RdapServer();
}
$server->setTld($tldReference)->setUrl($rdapServerUrl)->updateTimestamps();
$server
->setTld($tldReference)
->setUrl($rdapServerUrl)
->setUpdatedAt(new \DateTimeImmutable(array_key_exists('publication', $dnsRoot) ? $dnsRoot['publication'] : 'now'));
$this->em->persist($server);
}
@@ -451,6 +509,19 @@ readonly class RDAPService
$this->em->flush();
}
/**
* @throws ORMException
*/
public function updateRDAPServersFromFile(string $fileName): void
{
if (!file_exists($fileName)) {
return;
}
$this->logger->info('Start of update the RDAP server list from custom config file.');
$this->updateRDAPServers(Yaml::parseFile($fileName));
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Service;
use Psr\Cache\CacheItemPoolInterface;
readonly class StatService
{
public function __construct(
private CacheItemPoolInterface $pool
) {
}
public function incrementStat(string $key): bool
{
try {
$item = $this->pool->getItem($key);
$item->set(($item->get() ?? 0) + 1);
return $this->pool->save($item);
} catch (\Throwable) {
}
return false;
}
}