chore: merge develop

This commit is contained in:
Maël Gangloff
2025-10-27 21:57:08 +01:00
161 changed files with 8743 additions and 3179 deletions

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Api\Extension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Entity;
use Doctrine\ORM\QueryBuilder;
class NotNullAccreditationIcannExtension implements QueryCollectionExtensionInterface
{
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
if (Entity::class !== $resourceClass) {
return;
}
if ($operation && 'icann_accreditations_collection' === $operation->getName()) {
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.icannAccreditation.status IS NOT NULL', $rootAlias));
}
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Command;
use App\Message\ProcessWatchListsTrigger;
use App\Message\ProcessWatchlistTrigger;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@@ -12,10 +12,10 @@ use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsCommand(
name: 'app:process-watchlists',
description: 'Process watchlists and send emails if necessary',
name: 'app:process-watchlist',
description: 'Process watchlist and send emails if necessary',
)]
class ProcessWatchlistsCommand extends Command
class ProcessWatchlistCommand extends Command
{
public function __construct(private readonly MessageBusInterface $bus)
{
@@ -33,7 +33,7 @@ class ProcessWatchlistsCommand extends Command
{
$io = new SymfonyStyle($input, $output);
$this->bus->dispatch(new ProcessWatchListsTrigger());
$this->bus->dispatch(new ProcessWatchlistTrigger());
$io->success('Watchlist processing triggered!');

View File

@@ -2,7 +2,7 @@
namespace App\Command;
use App\Entity\WatchList;
use App\Entity\Watchlist;
use App\Message\SendDomainEventNotif;
use App\Repository\DomainRepository;
use App\Service\RDAPService;
@@ -48,7 +48,7 @@ class RegisterDomainCommand extends Command
try {
if (null !== $domain && !$force) {
if (!$domain->isToBeUpdated(true, true)) {
if (!$this->rdapService->isToBeUpdated($domain, true, true)) {
$io->warning('The domain name is already present in the database and does not need to be updated at this time.');
return Command::SUCCESS;
@@ -60,11 +60,11 @@ class RegisterDomainCommand extends Command
if ($notif) {
$randomizer = new Randomizer();
$watchLists = $randomizer->shuffleArray($domain->getWatchLists()->toArray());
$watchlists = $randomizer->shuffleArray($domain->getWatchlists()->toArray());
/** @var WatchList $watchList */
foreach ($watchLists as $watchList) {
$this->bus->dispatch(new SendDomainEventNotif($watchList->getToken(), $domain->getLdhName(), $updatedAt));
/** @var Watchlist $watchlist */
foreach ($watchlists as $watchlist) {
$this->bus->dispatch(new SendDomainEventNotif($watchlist->getToken(), $domain->getLdhName(), $updatedAt));
}
}
} catch (\Throwable $e) {

View File

@@ -2,12 +2,12 @@
namespace App\Config;
use App\Service\Connector\AutodnsProvider;
use App\Service\Connector\EppClientProvider;
use App\Service\Connector\GandiProvider;
use App\Service\Connector\NamecheapProvider;
use App\Service\Connector\NameComProvider;
use App\Service\Connector\OvhProvider;
use App\Service\Provider\AutodnsProvider;
use App\Service\Provider\EppClientProvider;
use App\Service\Provider\GandiProvider;
use App\Service\Provider\NamecheapProvider;
use App\Service\Provider\NameComProvider;
use App\Service\Provider\OvhProvider;
enum ConnectorProvider: string
{

View File

@@ -1,9 +0,0 @@
<?php
namespace App\Config;
enum TriggerAction: string
{
case SendEmail = 'email';
case SendChat = 'chat';
}

View File

@@ -1,90 +0,0 @@
<?php
namespace App\Controller;
use App\Entity\Domain;
use App\Entity\WatchList;
use App\Message\SendDomainEventNotif;
use App\Repository\DomainRepository;
use App\Service\RDAPService;
use Psr\Log\LoggerInterface;
use Random\Randomizer;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
class DomainRefreshController extends AbstractController
{
public function __construct(private readonly DomainRepository $domainRepository,
private readonly RDAPService $RDAPService,
private readonly RateLimiterFactory $rdapRequestsLimiter,
private readonly MessageBusInterface $bus,
private readonly LoggerInterface $logger,
private readonly KernelInterface $kernel,
) {
}
/**
* @throws TransportExceptionInterface
* @throws DecodingExceptionInterface
* @throws ExceptionInterface
* @throws \Exception
* @throws HttpExceptionInterface
* @throws \Throwable
*/
public function __invoke(string $ldhName, Request $request): Domain
{
$idnDomain = RDAPService::convertToIdn($ldhName);
$userId = $this->getUser()->getUserIdentifier();
$this->logger->info('User {username} wants to update the domain name {idnDomain}.', [
'username' => $userId,
'idnDomain' => $idnDomain,
]);
/** @var ?Domain $domain */
$domain = $this->domainRepository->findOneBy(['ldhName' => $idnDomain]);
// If the domain name exists in the database, recently updated and not important, we return the stored Domain
if (null !== $domain
&& !$domain->getDeleted()
&& !$domain->isToBeUpdated(true, true)
&& !$this->kernel->isDebug()
&& true !== filter_var($request->get('forced', false), FILTER_VALIDATE_BOOLEAN)
) {
$this->logger->info('It is not necessary to update the information of the domain name {idnDomain} with the RDAP protocol.', [
'idnDomain' => $idnDomain,
]);
return $domain;
}
if (false === $this->kernel->isDebug() && true === $this->getParameter('limited_features')) {
$limiter = $this->rdapRequestsLimiter->create($userId);
$limit = $limiter->consume();
if (!$limit->isAccepted()) {
throw new TooManyRequestsHttpException($limit->getRetryAfter()->getTimestamp() - time());
}
}
$updatedAt = null === $domain ? new \DateTimeImmutable('now') : $domain->getUpdatedAt();
$domain = $this->RDAPService->registerDomain($idnDomain);
$randomizer = new Randomizer();
$watchLists = $randomizer->shuffleArray($domain->getWatchLists()->toArray());
/** @var WatchList $watchList */
foreach ($watchLists as $watchList) {
$this->bus->dispatch(new SendDomainEventNotif($watchList->getToken(), $domain->getLdhName(), $updatedAt));
}
return $domain;
}
}

View File

@@ -5,15 +5,19 @@ namespace App\Controller;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\RouterInterface;
class HomeController extends AbstractController
{
public function __construct(private readonly RouterInterface $router)
{
public function __construct(
private readonly RouterInterface $router,
private readonly ParameterBagInterface $parameterBag,
) {
}
#[Route(path: '/', name: 'index')]
@@ -25,7 +29,10 @@ class HomeController extends AbstractController
#[Route(path: '/login/oauth', name: 'oauth_connect')]
public function connectAction(ClientRegistry $clientRegistry): Response
{
return $clientRegistry->getClient('oauth')->redirect([], []);
if ($this->parameterBag->get('oauth_enabled')) {
return $clientRegistry->getClient('oauth')->redirect([], []);
}
throw new NotFoundHttpException();
}
#[Route(path: '/logout', name: 'logout')]

View File

@@ -21,6 +21,7 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class RegistrationController extends AbstractController
{
@@ -33,6 +34,7 @@ class RegistrationController extends AbstractController
private readonly SerializerInterface $serializer,
private readonly LoggerInterface $logger,
private readonly KernelInterface $kernel,
private readonly ValidatorInterface $validator,
) {
}
@@ -44,7 +46,7 @@ class RegistrationController extends AbstractController
name: 'user_register',
defaults: [
'_api_resource_class' => User::class,
'_api_operation_name' => 'register',
'_api_operation_name' => 'user_register',
],
methods: ['POST']
)]
@@ -64,22 +66,21 @@ class RegistrationController extends AbstractController
}
$user = $this->serializer->deserialize($request->getContent(), User::class, 'json', ['groups' => 'user:register']);
if (null === $user->getEmail() || null === $user->getPassword()) {
throw new BadRequestHttpException('Bad request');
$violations = $this->validator->validate($user);
if ($violations->count() > 0) {
throw new BadRequestHttpException($violations->get(0));
}
$user->setPassword(
$userPasswordHasher->hashPassword(
$user,
$user->getPassword()
$user->getPlainPassword()
)
);
$this->em->persist($user);
$this->em->flush();
)->setCreatedAt(new \DateTimeImmutable());
if (false === (bool) $this->getParameter('registration_verify_email')) {
$user->setVerified(true);
$user->setVerifiedAt($user->getCreatedAt());
} else {
$email = $this->emailVerifier->sendEmailConfirmation('app_verify_email', $user,
(new TemplatedEmail())
@@ -91,13 +92,16 @@ class RegistrationController extends AbstractController
);
$signedUrl = (string) $email->getContext()['signedUrl'];
$this->logger->notice('The validation link for user {username} is {signedUrl}', [
$this->logger->notice('The validation link for this user is generated', [
'username' => $user->getUserIdentifier(),
'signedUrl' => $signedUrl,
]);
}
$this->logger->info('A new user has registered ({username}).', [
$this->em->persist($user);
$this->em->flush();
$this->logger->info('New user has registered', [
'username' => $user->getUserIdentifier(),
]);
@@ -121,7 +125,7 @@ class RegistrationController extends AbstractController
$this->emailVerifier->handleEmailConfirmation($request, $user);
$this->logger->info('User {username} has validated his email address.', [
$this->logger->info('User has validated his email address', [
'username' => $user->getUserIdentifier(),
]);

View File

@@ -4,7 +4,7 @@ namespace App\Controller;
use App\Entity\Statistics;
use App\Repository\DomainRepository;
use App\Repository\WatchListRepository;
use App\Repository\WatchlistRepository;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -15,7 +15,7 @@ class StatisticsController extends AbstractController
public function __construct(
private readonly CacheItemPoolInterface $pool,
private readonly DomainRepository $domainRepository,
private readonly WatchListRepository $watchListRepository,
private readonly WatchlistRepository $watchlistRepository,
private readonly KernelInterface $kernel,
) {
}
@@ -34,22 +34,10 @@ class StatisticsController extends AbstractController
->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])
$this->getCachedItem('stats.domain.tracked', fn () => $this->watchlistRepository->getTrackedDomainCount())
)
->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())
$this->getCachedItem('stats.domain.count', fn () => $this->domainRepository->getActiveDomainCountByTld())
)
->setDomainCountTotal(
$this->getCachedItem('stats.domain.total', fn () => $this->domainRepository->count(['deleted' => false])

View File

@@ -6,8 +6,11 @@ use App\Entity\Domain;
use App\Entity\DomainEvent;
use App\Entity\DomainStatus;
use App\Entity\User;
use App\Entity\WatchList;
use App\Repository\WatchListRepository;
use App\Entity\Watchlist;
use App\Repository\DomainRepository;
use App\Repository\WatchlistRepository;
use App\Service\CalendarService;
use App\Service\RDAPService;
use Doctrine\Common\Collections\Collection;
use Eluceo\iCal\Domain\Entity\Calendar;
use Eluceo\iCal\Presentation\Component\Property;
@@ -23,10 +26,13 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class WatchListController extends AbstractController
class WatchlistController extends AbstractController
{
public function __construct(
private readonly WatchListRepository $watchListRepository,
private readonly WatchlistRepository $watchlistRepository,
private readonly RDAPService $RDAPService,
private readonly CalendarService $calendarService,
private readonly DomainRepository $domainRepository,
) {
}
@@ -34,17 +40,17 @@ class WatchListController extends AbstractController
path: '/api/watchlists',
name: 'watchlist_get_all_mine',
defaults: [
'_api_resource_class' => WatchList::class,
'_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'get_all_mine',
],
methods: ['GET']
)]
public function getWatchLists(): Collection
public function getWatchlists(): Collection
{
/** @var User $user */
$user = $this->getUser();
return $user->getWatchLists();
return $user->getWatchlists();
}
/**
@@ -57,26 +63,26 @@ class WatchListController extends AbstractController
path: '/api/watchlists/{token}/calendar',
name: 'watchlist_calendar',
defaults: [
'_api_resource_class' => WatchList::class,
'_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'calendar',
]
)]
public function getWatchlistCalendar(string $token): Response
{
/** @var WatchList $watchList */
$watchList = $this->watchListRepository->findOneBy(['token' => $token]);
/** @var Watchlist $watchlist */
$watchlist = $this->watchlistRepository->findOneBy(['token' => $token]);
$calendar = new Calendar();
/** @var Domain $domain */
foreach ($watchList->getDomains()->getIterator() as $domain) {
foreach ($domain->getDomainCalendarEvents() as $event) {
foreach ($watchlist->getDomains()->getIterator() as $domain) {
foreach ($this->calendarService->getDomainCalendarEvents($domain) as $event) {
$calendar->addEvent($event);
}
}
$calendarResponse = (new CalendarFactory())->createCalendar($calendar);
$calendarName = $watchList->getName();
$calendarName = $watchlist->getName();
if (null !== $calendarName) {
$calendarResponse->withProperty(new Property('X-WR-CALNAME', new TextValue($calendarName)));
}
@@ -93,7 +99,7 @@ class WatchListController extends AbstractController
path: '/api/tracked',
name: 'watchlist_get_tracked_domains',
defaults: [
'_api_resource_class' => WatchList::class,
'_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'get_tracked_domains',
]
)]
@@ -102,18 +108,9 @@ class WatchListController extends AbstractController
/** @var User $user */
$user = $this->getUser();
$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 && !in_array($domain, $domains)) {
$domains[] = $domain;
}
}
$domains = $this->domainRepository->getMyTrackedDomains($user);
foreach ($domains as $domain) {
$domain->setExpiresInDays($this->RDAPService->getExpiresInDays($domain));
}
usort($domains, fn (Domain $d1, Domain $d2) => $d1->getExpiresInDays() - $d2->getExpiresInDays());
@@ -128,14 +125,14 @@ class WatchListController extends AbstractController
path: '/api/watchlists/{token}/rss/events',
name: 'watchlist_rss_events',
defaults: [
'_api_resource_class' => WatchList::class,
'_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'rss_events',
]
)]
public function getWatchlistRssEventsFeed(string $token, Request $request): Response
{
/** @var WatchList $watchlist */
$watchlist = $this->watchListRepository->findOneBy(['token' => $token]);
/** @var Watchlist $watchlist */
$watchlist = $this->watchlistRepository->findOneBy(['token' => $token]);
$feed = (new Feed())
->setLanguage('en')
@@ -166,14 +163,14 @@ class WatchListController extends AbstractController
path: '/api/watchlists/{token}/rss/status',
name: 'watchlist_rss_status',
defaults: [
'_api_resource_class' => WatchList::class,
'_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'rss_status',
]
)]
public function getWatchlistRssStatusFeed(string $token, Request $request): Response
{
/** @var WatchList $watchlist */
$watchlist = $this->watchListRepository->findOneBy(['token' => $token]);
/** @var Watchlist $watchlist */
$watchlist = $this->watchlistRepository->findOneBy(['token' => $token]);
$feed = (new Feed())
->setLanguage('en')

View File

@@ -0,0 +1,15 @@
<?php
namespace App\DataFixtures;
use App\Story\DefaultUsersStory;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
DefaultUsersStory::load();
}
}

View File

@@ -27,7 +27,7 @@ use Symfony\Component\Uid\Uuid;
),
new Get(
normalizationContext: ['groups' => 'connector:list'],
security: 'object.user == user'
security: 'object.getUser() == user'
),
new Post(
normalizationContext: ['groups' => ['connector:create', 'connector:list']],
@@ -35,7 +35,7 @@ use Symfony\Component\Uid\Uuid;
processor: ConnectorCreateProcessor::class
),
new Delete(
security: 'object.user == user',
security: 'object.getUser() == user',
processor: ConnectorDeleteProcessor::class
),
]
@@ -61,10 +61,10 @@ class Connector
private array $authData = [];
/**
* @var Collection<int, WatchList>
* @var Collection<int, Watchlist>
*/
#[ORM\OneToMany(targetEntity: WatchList::class, mappedBy: 'connector')]
private Collection $watchLists;
#[ORM\OneToMany(targetEntity: Watchlist::class, mappedBy: 'connector')]
private Collection $watchlists;
#[Groups(['connector:list', 'watchlist:list'])]
#[ORM\Column]
@@ -76,7 +76,7 @@ class Connector
public function __construct()
{
$this->id = Uuid::v4();
$this->watchLists = new ArrayCollection();
$this->watchlists = new ArrayCollection();
}
public function getId(): ?string
@@ -121,29 +121,29 @@ class Connector
}
/**
* @return Collection<int, WatchList>
* @return Collection<int, Watchlist>
*/
public function getWatchLists(): Collection
public function getWatchlists(): Collection
{
return $this->watchLists;
return $this->watchlists;
}
public function addWatchList(WatchList $watchList): static
public function addWatchlist(Watchlist $watchlist): static
{
if (!$this->watchLists->contains($watchList)) {
$this->watchLists->add($watchList);
$watchList->setConnector($this);
if (!$this->watchlists->contains($watchlist)) {
$this->watchlists->add($watchlist);
$watchlist->setConnector($this);
}
return $this;
}
public function removeWatchList(WatchList $watchList): static
public function removeWatchlist(Watchlist $watchlist): static
{
if ($this->watchLists->removeElement($watchList)) {
if ($this->watchlists->removeElement($watchlist)) {
// set the owning side to null (unless already changed)
if ($watchList->getConnector() === $this) {
$watchList->setConnector(null);
if ($watchlist->getConnector() === $this) {
$watchlist->setConnector(null);
}
}
@@ -164,6 +164,6 @@ class Connector
public function getWatchlistCount(): ?int
{
return $this->watchLists->count();
return $this->watchlists->count();
}
}

View File

@@ -2,29 +2,21 @@
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\QueryParameter;
use App\Config\EventAction;
use App\Controller\DomainRefreshController;
use App\Exception\MalformedDomainException;
use App\Repository\DomainRepository;
use App\Service\RDAPService;
use App\State\AutoRegisterDomainProvider;
use App\State\FindDomainCollectionFromEntityProvider;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Eluceo\iCal\Domain\Entity\Attendee;
use Eluceo\iCal\Domain\Entity\Event;
use Eluceo\iCal\Domain\Enum\EventStatus;
use Eluceo\iCal\Domain\ValueObject\Category;
use Eluceo\iCal\Domain\ValueObject\Date;
use Eluceo\iCal\Domain\ValueObject\EmailAddress;
use Eluceo\iCal\Domain\ValueObject\SingleDay;
use Eluceo\iCal\Domain\ValueObject\Timestamp;
use Sabre\VObject\EofException;
use Sabre\VObject\InvalidDataException;
use Sabre\VObject\ParseException;
use Sabre\VObject\Reader;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
@@ -40,9 +32,23 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
]
),
*/
new GetCollection(
uriTemplate: '/domains',
normalizationContext: [
'groups' => [
'domain:list',
'tld:list',
'event:list',
'event:list',
],
],
provider: FindDomainCollectionFromEntityProvider::class,
parameters: [
'registrant' => new QueryParameter(description: 'The exact name of the registrant (case insensitive)', required: true),
]
),
new Get(
uriTemplate: '/domains/{ldhName}', // Do not delete this line, otherwise Symfony interprets the TLD of the domain name as a return type
controller: DomainRefreshController::class,
normalizationContext: [
'groups' => [
'domain:item',
@@ -54,7 +60,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
'ds:list',
],
],
read: false
),
],
provider: AutoRegisterDomainProvider::class
@@ -75,25 +80,30 @@ class Domain
*/
#[ORM\OneToMany(targetEntity: DomainEvent::class, mappedBy: 'domain', cascade: ['persist'], orphanRemoval: true)]
#[Groups(['domain:item', 'domain:list', 'watchlist:list'])]
#[ApiProperty(
openapiContext: [
'type' => 'array',
]
)]
private Collection $events;
/**
* @var Collection<int, DomainEntity>
*/
#[ORM\OneToMany(targetEntity: DomainEntity::class, mappedBy: 'domain', cascade: ['persist'], orphanRemoval: true)]
#[Groups(['domain:item'])]
#[Groups(['domain:item', 'watchlist:item'])]
#[SerializedName('entities')]
private Collection $domainEntities;
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['domain:item', 'domain:list', 'watchlist:item', 'watchlist:list'])]
private array $status = [];
/**
* @var Collection<int, WatchList>
* @var Collection<int, Watchlist>
*/
#[ORM\ManyToMany(targetEntity: WatchList::class, mappedBy: 'domains', cascade: ['persist'])]
private Collection $watchLists;
#[ORM\ManyToMany(targetEntity: Watchlist::class, mappedBy: 'domains', cascade: ['persist'])]
private Collection $watchlists;
/**
* @var Collection<int, Nameserver>
@@ -103,7 +113,7 @@ class Domain
joinColumns: [new ORM\JoinColumn(name: 'domain_ldh_name', referencedColumnName: 'ldh_name')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'nameserver_ldh_name', referencedColumnName: 'ldh_name')]
)]
#[Groups(['domain:item'])]
#[Groups(['domain:item', 'watchlist:item'])]
private Collection $nameservers;
#[ORM\Column(type: Types::DATE_IMMUTABLE)]
@@ -135,7 +145,7 @@ class Domain
#[ORM\Column(nullable: false, options: ['default' => false])]
#[Groups(['domain:item', 'domain:list'])]
private ?bool $delegationSigned = null;
private bool $delegationSigned = false;
/**
* @var Collection<int, DnsKey>
@@ -144,6 +154,8 @@ class Domain
#[Groups(['domain:item'])]
private Collection $dnsKey;
private ?int $expiresInDays;
private const IMPORTANT_EVENTS = [EventAction::Deletion->value, EventAction::Expiration->value];
private const IMPORTANT_STATUS = [
'redemption period',
@@ -160,7 +172,7 @@ class Domain
{
$this->events = new ArrayCollection();
$this->domainEntities = new ArrayCollection();
$this->watchLists = new ArrayCollection();
$this->watchlists = new ArrayCollection();
$this->nameservers = new ArrayCollection();
$this->updatedAt = new \DateTimeImmutable('now');
$this->createdAt = $this->updatedAt;
@@ -174,6 +186,9 @@ class Domain
return $this->ldhName;
}
/**
* @throws MalformedDomainException
*/
public function setLdhName(string $ldhName): static
{
$this->ldhName = RDAPService::convertToIdn($ldhName);
@@ -266,27 +281,27 @@ class Domain
}
/**
* @return Collection<int, WatchList>
* @return Collection<int, Watchlist>
*/
public function getWatchLists(): Collection
public function getWatchlists(): Collection
{
return $this->watchLists;
return $this->watchlists;
}
public function addWatchList(WatchList $watchList): static
public function addWatchlists(Watchlist $watchlist): static
{
if (!$this->watchLists->contains($watchList)) {
$this->watchLists->add($watchList);
$watchList->addDomain($this);
if (!$this->watchlists->contains($watchlist)) {
$this->watchlists->add($watchlist);
$watchlist->addDomain($this);
}
return $this;
}
public function removeWatchList(WatchList $watchList): static
public function removeWatchlists(Watchlist $watchlist): static
{
if ($this->watchLists->removeElement($watchList)) {
$watchList->removeDomain($this);
if ($this->watchlists->removeElement($watchlist)) {
$watchlist->removeDomain($this);
}
return $this;
@@ -333,7 +348,7 @@ class Domain
return $this;
}
private function setUpdatedAt(?\DateTimeImmutable $updatedAt): void
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
@@ -378,7 +393,7 @@ class Domain
*
* @throws \Exception
*/
protected function isToBeWatchClosely(): bool
public function isToBeWatchClosely(): bool
{
$status = $this->getStatus();
if ((!empty($status) && count(array_intersect($status, self::IMPORTANT_STATUS))) || $this->getDeleted()) {
@@ -395,47 +410,6 @@ class Domain
return !empty($events) && in_array($events[0]->getAction(), self::IMPORTANT_EVENTS);
}
/**
* Returns true if one or more of these conditions are met:
* - It has been more than 7 days since the domain name was last updated
* - It has been more than 12 minutes and the domain name has statuses that suggest it is not stable
* - It has been more than 1 day and the domain name is blocked in DNS
*
* @throws \Exception
*/
public function isToBeUpdated(bool $fromUser = true, bool $intensifyLastDay = false): bool
{
$updatedAtDiff = $this->getUpdatedAt()->diff(new \DateTimeImmutable());
if ($updatedAtDiff->days >= 7) {
return true;
}
if ($this->getDeleted()) {
return $fromUser;
}
$expiresIn = $this->getExpiresInDays();
if ($intensifyLastDay && (0 === $expiresIn || 1 === $expiresIn)) {
return true;
}
$minutesDiff = $updatedAtDiff->h * 60 + $updatedAtDiff->i;
if (($minutesDiff >= 12 || $fromUser) && $this->isToBeWatchClosely()) {
return true;
}
if (
count(array_intersect($this->getStatus(), ['auto renew period', 'client hold', 'server hold'])) > 0
&& $updatedAtDiff->days >= 1
) {
return true;
}
return false;
}
/**
* @return Collection<int, DomainStatus>
*/
@@ -500,122 +474,6 @@ class Domain
return in_array('pending delete', $this->getStatus()) && !in_array('redemption period', $this->getStatus());
}
/**
* @throws \DateMalformedIntervalStringException
*/
private function calculateDaysFromStatus(\DateTimeImmutable $now): ?int
{
$lastStatus = $this->getDomainStatuses()->last();
if (false === $lastStatus) {
return null;
}
if ($this->isPendingDelete() && (
in_array('pending delete', $lastStatus->getAddStatus())
|| in_array('redemption period', $lastStatus->getDeleteStatus()))
) {
return self::daysBetween($now, $lastStatus->getCreatedAt()->add(new \DateInterval('P'. 5 .'D')));
}
if ($this->isRedemptionPeriod()
&& in_array('redemption period', $lastStatus->getAddStatus())
) {
return self::daysBetween($now, $lastStatus->getCreatedAt()->add(new \DateInterval('P'.(30 + 5).'D')));
}
return null;
}
/*
private function calculateDaysFromEvents(\DateTimeImmutable $now): ?int
{
$lastChangedEvent = $this->getEvents()->findFirst(fn (int $i, DomainEvent $e) => !$e->getDeleted() && EventAction::LastChanged->value === $e->getAction());
if (null === $lastChangedEvent) {
return null;
}
if ($this->isRedemptionPeriod()) {
return self::daysBetween($now, $lastChangedEvent->getDate()->add(new \DateInterval('P'.(30 + 5).'D')));
}
if ($this->isPendingDelete()) {
return self::daysBetween($now, $lastChangedEvent->getDate()->add(new \DateInterval('P'. 5 .'D')));
}
return null;
}
*/
private static function daysBetween(\DateTimeImmutable $start, \DateTimeImmutable $end): int
{
$interval = $start->setTime(0, 0)->diff($end->setTime(0, 0));
return $interval->invert ? -$interval->days : $interval->days;
}
private static function returnExpiresIn(array $guesses): ?int
{
$filteredGuesses = array_filter($guesses, function ($value) {
return null !== $value;
});
if (empty($filteredGuesses)) {
return null;
}
return max(min($filteredGuesses), 0);
}
/**
* @throws \Exception
*/
private function getRelevantDates(): array
{
$expiredAt = $deletedAt = null;
foreach ($this->getEvents()->getIterator() as $event) {
if (!$event->getDeleted()) {
if ('expiration' === $event->getAction()) {
$expiredAt = $event->getDate();
} elseif ('deletion' === $event->getAction()) {
$deletedAt = $event->getDate();
}
}
}
return [$expiredAt, $deletedAt];
}
/**
* @throws \Exception
*/
#[Groups(['domain:item', 'domain:list'])]
public function getExpiresInDays(): ?int
{
if ($this->getDeleted()) {
return null;
}
$now = new \DateTimeImmutable();
[$expiredAt, $deletedAt] = $this->getRelevantDates();
if ($expiredAt) {
$guess = self::daysBetween($now, $expiredAt->add(new \DateInterval('P'.(45 + 30 + 5).'D')));
}
if ($deletedAt) {
// It has been observed that AFNIC, on the last day, adds a "deleted" event and removes the redemption period status.
if (0 === self::daysBetween($now, $deletedAt) && $this->isPendingDelete()) {
return 0;
}
$guess = self::daysBetween($now, $deletedAt->add(new \DateInterval('P'. 30 .'D')));
}
return self::returnExpiresIn([
$guess ?? null,
$this->calculateDaysFromStatus($now),
]);
}
/**
* @return Collection<int, DnsKey>
*/
@@ -646,69 +504,16 @@ class Domain
return $this;
}
/**
* @return Event[]
*
* @throws ParseException
* @throws EofException
* @throws InvalidDataException
* @throws \Exception
*/
public function getDomainCalendarEvents(): array
#[Groups(['domain:item', 'domain:list'])]
public function getExpiresInDays(): ?int
{
$events = [];
$attendees = [];
return $this->expiresInDays;
}
/* @var DomainEntity $entity */
foreach ($this->getDomainEntities()->filter(fn (DomainEntity $domainEntity) => !$domainEntity->getDeleted())->getIterator() as $domainEntity) {
$jCard = $domainEntity->getEntity()->getJCard();
public function setExpiresInDays(?int $expiresInDays): static
{
$this->expiresInDays = $expiresInDays;
if (empty($jCard)) {
continue;
}
$vCardData = Reader::readJson($jCard);
if (empty($vCardData->EMAIL) || empty($vCardData->FN)) {
continue;
}
$email = (string) $vCardData->EMAIL;
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
$attendees[] = (new Attendee(new EmailAddress($email)))->setDisplayName((string) $vCardData->FN);
}
/** @var DomainEvent $event */
foreach ($this->getEvents()->filter(fn (DomainEvent $e) => $e->getDate()->diff(new \DateTimeImmutable('now'))->y <= 10)->getIterator() as $event) {
$events[] = (new Event())
->setLastModified(new Timestamp($this->getUpdatedAt()))
->setStatus(EventStatus::CONFIRMED())
->setSummary($this->getLdhName().': '.$event->getAction())
->addCategory(new Category($event->getAction()))
->setAttendees($attendees)
->setOccurrence(new SingleDay(new Date($event->getDate()))
);
}
$expiresInDays = $this->getExpiresInDays();
if (null !== $expiresInDays) {
$events[] = (new Event())
->setLastModified(new Timestamp($this->getUpdatedAt()))
->setStatus(EventStatus::CONFIRMED())
->setSummary($this->getLdhName().': estimated WHOIS release date')
->addCategory(new Category('release'))
->setAttendees($attendees)
->setOccurrence(new SingleDay(new Date(
(new \DateTimeImmutable())->setTime(0, 0)->add(new \DateInterval('P'.$expiresInDays.'D'))
))
);
}
return $events;
return $this;
}
}

View File

@@ -23,18 +23,13 @@ class DomainEntity
#[Groups(['domain-entity:entity'])]
private ?Entity $entity = null;
#[ORM\Column(type: Types::SIMPLE_ARRAY)]
#[ORM\Column(type: Types::JSON)]
#[Groups(['domain-entity:entity', 'domain-entity:domain'])]
private array $roles = [];
#[ORM\Column]
#[ORM\Column(nullable: true)]
#[Groups(['domain-entity:entity', 'domain-entity:domain'])]
private ?bool $deleted;
public function __construct()
{
$this->deleted = false;
}
private ?\DateTimeImmutable $deletedAt = null;
public function getDomain(): ?Domain
{
@@ -75,14 +70,14 @@ class DomainEntity
return $this;
}
public function getDeleted(): ?bool
public function getDeletedAt(): ?\DateTimeImmutable
{
return $this->deleted;
return $this->deletedAt;
}
public function setDeleted(?bool $deleted): static
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
{
$this->deleted = $deleted;
$this->deletedAt = $deletedAt;
return $this;
}

View File

@@ -27,11 +27,11 @@ class DomainStatus
#[Groups(['domain:item'])]
private \DateTimeImmutable $date;
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['domain:item'])]
private array $addStatus = [];
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['domain:item'])]
private array $deleteStatus = [];

View File

@@ -2,15 +2,12 @@
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Repository\EntityRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Embedded;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
@@ -20,31 +17,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
)]
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/entities/icann-accreditations',
openapiContext: [
'parameters' => [
[
'name' => 'icannAccreditation.status',
'in' => 'query',
'required' => true,
'schema' => [
'type' => 'array',
'items' => [
'type' => 'string',
'enum' => ['Accredited', 'Terminated', 'Reserved'],
],
],
'style' => 'form',
'explode' => true,
'description' => 'Filter by ICANN accreditation status',
],
],
],
description: 'ICANN Registrar IDs list',
normalizationContext: ['groups' => ['entity:list']],
name: 'icann_accreditations_collection'
),
/*
new GetCollection(
uriTemplate: '/entities',
@@ -67,12 +39,6 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
*/
]
)]
#[ApiFilter(
SearchFilter::class,
properties: [
'icannAccreditation.status' => 'exact',
]
)]
class Entity
{
#[ORM\Id]
@@ -81,12 +47,12 @@ class Entity
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Tld::class, inversedBy: 'entities')]
#[ORM\JoinColumn(referencedColumnName: 'tld', nullable: true)]
#[Groups(['entity:list', 'entity:item', 'domain:item'])]
#[ORM\JoinColumn(referencedColumnName: 'tld', nullable: false)]
#[Groups(['entity:list', 'entity:item', 'domain:item', 'watchlist:item'])]
private ?Tld $tld = null;
#[ORM\Column(length: 255)]
#[Groups(['entity:list', 'entity:item', 'domain:item'])]
#[Groups(['entity:list', 'entity:item', 'domain:item', 'watchlist:item'])]
private ?string $handle = null;
/**
@@ -112,15 +78,21 @@ class Entity
#[Groups(['entity:item', 'domain:item'])]
private Collection $events;
#[ORM\Column]
#[Groups(['entity:item', 'domain:item'])]
#[ORM\Column(type: 'json')]
#[ApiProperty(
openapiContext: [
'type' => 'array',
'items' => ['type' => 'array'],
]
)]
#[Groups(['entity:item', 'domain:item', 'watchlist:item'])]
private array $jCard = [];
#[ORM\Column(nullable: true)]
#[Groups(['entity:item', 'domain:item'])]
#[Groups(['entity:item', 'domain:item', 'watchlist:item'])]
private ?array $remarks = null;
#[Embedded(class: IcannAccreditation::class, columnPrefix: 'icann_')]
#[ORM\ManyToOne(inversedBy: 'entities')]
#[Groups(['entity:list', 'entity:item', 'domain:item'])]
private ?IcannAccreditation $icannAccreditation = null;
@@ -129,7 +101,6 @@ class Entity
$this->domainEntities = new ArrayCollection();
$this->nameserverEntities = new ArrayCollection();
$this->events = new ArrayCollection();
$this->icannAccreditation = new IcannAccreditation();
}
public function getHandle(): ?string
@@ -284,11 +255,13 @@ class Entity
public function getIcannAccreditation(): ?IcannAccreditation
{
return null === $this->icannAccreditation->getStatus() ? null : $this->icannAccreditation;
return $this->icannAccreditation;
}
public function setIcannAccreditation(?IcannAccreditation $icannAccreditation): void
public function setIcannAccreditation(?IcannAccreditation $icannAccreditation): static
{
$this->icannAccreditation = $icannAccreditation;
return $this;
}
}

View File

@@ -2,35 +2,92 @@
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\Config\RegistrarStatus;
use App\Repository\IcannAccreditationRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Embeddable;
use Symfony\Component\Serializer\Attribute\Groups;
#[Embeddable]
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/icann-accreditations',
openapiContext: [
'parameters' => [
[
'name' => 'status',
'in' => 'query',
'required' => true,
'schema' => [
'type' => 'array',
'items' => [
'type' => 'string',
'enum' => ['Accredited', 'Terminated', 'Reserved'],
],
],
'style' => 'form',
'explode' => true,
'description' => 'Filter by ICANN accreditation status',
],
],
],
shortName: 'ICANN Accreditation',
description: 'ICANN Registrar IDs list',
normalizationContext: ['groups' => ['icann:list']]
),
]
)]
#[ApiFilter(
SearchFilter::class,
properties: [
'status' => 'exact',
]
)]
#[ORM\Entity(repositoryClass: IcannAccreditationRepository::class)]
class IcannAccreditation
{
#[ORM\Id]
#[ORM\Column]
#[Groups(['icann:item', 'icann:list', 'domain:item'])]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['entity:item', 'entity:list', 'domain:item'])]
#[Groups(['icann:item', 'icann:list', 'domain:item'])]
private ?string $registrarName = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['entity:item', 'domain:item'])]
#[Groups(['icann:item'])]
private ?string $rdapBaseUrl = null;
#[ORM\Column(nullable: true, enumType: RegistrarStatus::class)]
#[Groups(['entity:item', 'entity:list', 'domain:item'])]
#[Groups(['icann:item', 'icann:list', 'domain:item'])]
private ?RegistrarStatus $status = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
#[Groups(['entity:item', 'entity:list', 'domain:item'])]
#[Groups(['icann:item', 'icann:list', 'domain:item'])]
private ?\DateTimeImmutable $updated = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
#[Groups(['entity:item', 'entity:list', 'domain:item'])]
#[Groups(['icann:item', 'icann:list', 'domain:item'])]
private ?\DateTimeImmutable $date = null;
/**
* @var Collection<int, Entity>
*/
#[ORM\OneToMany(targetEntity: Entity::class, mappedBy: 'icannAccreditation')]
private Collection $entities;
public function __construct()
{
$this->entities = new ArrayCollection();
}
public function getRegistrarName(): ?string
{
return $this->registrarName;
@@ -90,4 +147,46 @@ class IcannAccreditation
return $this;
}
public function getId(): ?int
{
return $this->id;
}
public function setId(?int $id): static
{
$this->id = $id;
return $this;
}
/**
* @return Collection<int, Entity>
*/
public function getEntities(): Collection
{
return $this->entities;
}
public function addEntity(Entity $entity): static
{
if (!$this->entities->contains($entity)) {
$this->entities->add($entity);
$entity->setIcannAccreditation($this);
}
return $this;
}
public function removeEntity(Entity $entity): static
{
if ($this->entities->removeElement($entity)) {
// set the owning side to null (unless already changed)
if ($entity->getIcannAccreditation() === $this) {
$entity->setIcannAccreditation(null);
}
}
return $this;
}
}

View File

@@ -56,7 +56,6 @@ class Nameserver
* @var Collection<int, Domain>
*/
#[ORM\ManyToMany(targetEntity: Domain::class, mappedBy: 'nameservers')]
#[Groups(['nameserver:item'])]
private Collection $domains;
public function __construct()

View File

@@ -23,11 +23,11 @@ class NameserverEntity
#[Groups(['nameserver-entity:entity'])]
private ?Entity $entity = null;
#[ORM\Column(type: Types::SIMPLE_ARRAY)]
#[ORM\Column(type: Types::JSON)]
#[Groups(['nameserver-entity:entity', 'nameserver-entity:nameserver'])]
private array $roles = [];
#[ORM\Column(type: Types::SIMPLE_ARRAY)]
#[ORM\Column(type: Types::JSON)]
#[Groups(['nameserver-entity:entity', 'nameserver-entity:nameserver'])]
private array $status = [];

View File

@@ -77,6 +77,9 @@ class Tld
#[ORM\OneToMany(targetEntity: Entity::class, mappedBy: 'tld')]
private Collection $entities;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $deletedAt = null;
public function __construct()
{
$this->rdapServers = new ArrayCollection();
@@ -241,4 +244,16 @@ class Tld
return $this;
}
public function getDeletedAt(): ?\DateTimeImmutable
{
return $this->deletedAt;
}
public function setDeletedAt(?\DateTimeImmutable $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
}

View File

@@ -15,6 +15,8 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
@@ -46,6 +48,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 180)]
#[Groups(['user:list', 'user:register'])]
#[Assert\Email]
#[Assert\NotBlank]
private ?string $email = null;
/**
@@ -59,14 +63,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* @var string|null The hashed password
*/
#[ORM\Column(nullable: true)]
#[Groups(['user:register'])]
private ?string $password = null;
/**
* @var Collection<int, WatchList>
* @var Collection<int, Watchlist>
*/
#[ORM\OneToMany(targetEntity: WatchList::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $watchLists;
#[ORM\OneToMany(targetEntity: Watchlist::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $watchlists;
/**
* @var Collection<int, Connector>
@@ -75,11 +78,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private Collection $connectors;
#[ORM\Column]
private bool $isVerified = false;
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $verifiedAt = null;
#[Assert\PasswordStrength]
#[Assert\NotBlank]
#[SerializedName('password')]
#[Groups(['user:register'])]
private ?string $plainPassword = null;
public function __construct()
{
$this->watchLists = new ArrayCollection();
$this->watchlists = new ArrayCollection();
$this->connectors = new ArrayCollection();
}
@@ -152,33 +164,33 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
public function eraseCredentials(): void
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
$this->plainPassword = null;
}
/**
* @return Collection<int, WatchList>
* @return Collection<int, Watchlist>
*/
public function getWatchLists(): Collection
public function getWatchlists(): Collection
{
return $this->watchLists;
return $this->watchlists;
}
public function addWatchList(WatchList $watchList): static
public function addWatchlist(Watchlist $watchlist): static
{
if (!$this->watchLists->contains($watchList)) {
$this->watchLists->add($watchList);
$watchList->setUser($this);
if (!$this->watchlists->contains($watchlist)) {
$this->watchlists->add($watchlist);
$watchlist->setUser($this);
}
return $this;
}
public function removeWatchList(WatchList $watchList): static
public function removeWatchlist(Watchlist $watchlist): static
{
if ($this->watchLists->removeElement($watchList)) {
if ($this->watchlists->removeElement($watchlist)) {
// set the owning side to null (unless already changed)
if ($watchList->getUser() === $this) {
$watchList->setUser(null);
if ($watchlist->getUser() === $this) {
$watchlist->setUser(null);
}
}
@@ -215,14 +227,38 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function isVerified(): bool
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->isVerified;
return $this->createdAt;
}
public function setVerified(bool $isVerified): static
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->isVerified = $isVerified;
$this->createdAt = $createdAt;
return $this;
}
public function getVerifiedAt(): ?\DateTimeImmutable
{
return $this->verifiedAt;
}
public function setVerifiedAt(\DateTimeImmutable $verifiedAt): static
{
$this->verifiedAt = $verifiedAt;
return $this;
}
public function getPlainPassword(): ?string
{
return $this->plainPassword;
}
public function setPlainPassword(?string $plainPassword): self
{
$this->plainPassword = $plainPassword;
return $this;
}

View File

@@ -1,94 +0,0 @@
<?php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Post;
use App\Config\TriggerAction;
use App\Repository\EventTriggerRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: EventTriggerRepository::class)]
#[ApiResource(
uriTemplate: '/watchlists/{watchListId}/triggers/{action}/{event}',
shortName: 'Watchlist Trigger',
operations: [
new Get(),
new GetCollection(
uriTemplate: '/watchlists/{watchListId}/triggers',
uriVariables: [
'watchListId' => new Link(fromProperty: 'token', toProperty: 'watchList', fromClass: WatchList::class),
],
),
new Post(
uriTemplate: '/watchlist-triggers',
uriVariables: [],
security: 'true'
),
new Delete(),
],
uriVariables: [
'watchListId' => new Link(fromProperty: 'token', toProperty: 'watchList', fromClass: WatchList::class),
'action' => 'action',
'event' => 'event',
],
security: 'object.getWatchList().user == user',
)]
class WatchListTrigger
{
#[ORM\Id]
#[ORM\Column(length: 255, nullable: false)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
private ?string $event;
#[ORM\Id]
#[ORM\ManyToOne(targetEntity: WatchList::class, inversedBy: 'watchListTriggers')]
#[ORM\JoinColumn(referencedColumnName: 'token', nullable: false, onDelete: 'CASCADE')]
private ?WatchList $watchList;
#[ORM\Id]
#[ORM\Column(nullable: false, enumType: TriggerAction::class)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
private ?TriggerAction $action;
public function getEvent(): ?string
{
return $this->event;
}
public function setEvent(string $event): static
{
$this->event = $event;
return $this;
}
public function getWatchList(): ?WatchList
{
return $this->watchList;
}
public function setWatchList(?WatchList $watchList): static
{
$this->watchList = $watchList;
return $this;
}
public function getAction(): ?TriggerAction
{
return $this->action;
}
public function setAction(TriggerAction $action): static
{
$this->action = $action;
return $this;
}
}

View File

@@ -6,10 +6,11 @@ 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 ApiPlatform\Metadata\Put;
use App\Repository\WatchListRepository;
use App\State\WatchListUpdateProcessor;
use App\Repository\WatchlistRepository;
use App\State\WatchlistUpdateProcessor;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@@ -17,8 +18,9 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: WatchListRepository::class)]
#[ORM\Entity(repositoryClass: WatchlistRepository::class)]
#[ApiResource(
shortName: 'Watchlist',
operations: [
@@ -41,7 +43,6 @@ use Symfony\Component\Uid\Uuid;
'domain:list',
'tld:list',
'event:list',
'domain:list',
'event:list',
],
],
@@ -51,7 +52,7 @@ use Symfony\Component\Uid\Uuid;
normalizationContext: [
'groups' => [
'watchlist:item',
'domain:item',
'domain:list',
'event:list',
'domain-entity:entity',
'nameserver-entity:nameserver',
@@ -59,7 +60,7 @@ use Symfony\Component\Uid\Uuid;
'tld:item',
],
],
security: 'object.user == user'
security: 'object.getUser() == user'
),
new Get(
routeName: 'watchlist_calendar',
@@ -86,16 +87,22 @@ use Symfony\Component\Uid\Uuid;
new Post(
normalizationContext: ['groups' => 'watchlist:list'],
denormalizationContext: ['groups' => 'watchlist:create'],
processor: WatchListUpdateProcessor::class,
processor: WatchlistUpdateProcessor::class,
),
new Put(
normalizationContext: ['groups' => 'watchlist:item'],
denormalizationContext: ['groups' => ['watchlist:create', 'watchlist:token']],
security: 'object.user == user',
processor: WatchListUpdateProcessor::class,
normalizationContext: ['groups' => 'watchlist:list'],
denormalizationContext: ['groups' => ['watchlist:update']],
security: 'object.getUser() == user',
processor: WatchlistUpdateProcessor::class,
),
new Patch(
normalizationContext: ['groups' => 'watchlist:list'],
denormalizationContext: ['groups' => ['watchlist:update']],
security: 'object.getUser() == user',
processor: WatchlistUpdateProcessor::class,
),
new Delete(
security: 'object.user == user'
security: 'object.getUser() == user'
),
new Get(
routeName: 'watchlist_rss_status',
@@ -145,9 +152,9 @@ use Symfony\Component\Uid\Uuid;
),
],
)]
class WatchList
class Watchlist
{
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'watchLists')]
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'watchlists')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
public ?User $user = null;
@@ -159,27 +166,19 @@ class WatchList
/**
* @var Collection<int, Domain>
*/
#[ORM\ManyToMany(targetEntity: Domain::class, inversedBy: 'watchLists')]
#[ORM\JoinTable(name: 'watch_lists_domains',
joinColumns: [new ORM\JoinColumn(name: 'watch_list_token', referencedColumnName: 'token', onDelete: 'CASCADE')],
#[ORM\ManyToMany(targetEntity: Domain::class, inversedBy: 'watchlists')]
#[ORM\JoinTable(name: 'watchlist_domains',
joinColumns: [new ORM\JoinColumn(name: 'watchlist_token', referencedColumnName: 'token', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'domain_ldh_name', referencedColumnName: 'ldh_name', onDelete: 'CASCADE')])]
#[Groups(['watchlist:create', 'watchlist:list', 'watchlist:item'])]
#[Groups(['watchlist:create', 'watchlist:list', 'watchlist:item', 'watchlist:update'])]
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'])]
#[SerializedName('triggers')]
private Collection $watchListTriggers;
#[ORM\ManyToOne(inversedBy: 'watchLists')]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
#[ORM\ManyToOne(inversedBy: 'watchlists')]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
private ?Connector $connector = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
private ?string $name = null;
#[ORM\Column]
@@ -187,15 +186,42 @@ class WatchList
private ?\DateTimeImmutable $createdAt = null;
#[SerializedName('dsn')]
#[ORM\Column(type: Types::SIMPLE_ARRAY, nullable: true)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create'])]
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
#[Assert\Unique]
#[Assert\All([
new Assert\Type('string'),
new Assert\NotBlank(),
])]
private ?array $webhookDsn = null;
#[ORM\Column(type: Types::JSON)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
#[Assert\Unique]
#[Assert\NotBlank]
#[Assert\All([
new Assert\Type('string'),
new Assert\NotBlank(),
])]
private array $trackedEvents = [];
#[ORM\Column(type: Types::JSON)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
#[Assert\Unique]
#[Assert\All([
new Assert\Type('string'),
new Assert\NotBlank(),
])]
private array $trackedEppStatus = [];
#[ORM\Column(type: Types::BOOLEAN)]
#[Groups(['watchlist:list', 'watchlist:item', 'watchlist:create', 'watchlist:update'])]
private ?bool $enabled = null;
public function __construct()
{
$this->token = Uuid::v4();
$this->domains = new ArrayCollection();
$this->watchListTriggers = new ArrayCollection();
$this->createdAt = new \DateTimeImmutable('now');
}
@@ -245,36 +271,6 @@ class WatchList
return $this;
}
/**
* @return Collection<int, WatchListTrigger>
*/
public function getWatchListTriggers(): Collection
{
return $this->watchListTriggers;
}
public function addWatchListTrigger(WatchListTrigger $watchListTrigger): static
{
if (!$this->watchListTriggers->contains($watchListTrigger)) {
$this->watchListTriggers->add($watchListTrigger);
$watchListTrigger->setWatchList($this);
}
return $this;
}
public function removeWatchListTrigger(WatchListTrigger $watchListTrigger): static
{
if ($this->watchListTriggers->removeElement($watchListTrigger)) {
// set the owning side to null (unless already changed)
if ($watchListTrigger->getWatchList() === $this) {
$watchListTrigger->setWatchList(null);
}
}
return $this;
}
public function getConnector(): ?Connector
{
return $this->connector;
@@ -322,4 +318,40 @@ class WatchList
return $this;
}
public function getTrackedEvents(): array
{
return $this->trackedEvents;
}
public function setTrackedEvents(array $trackedEvents): static
{
$this->trackedEvents = $trackedEvents;
return $this;
}
public function getTrackedEppStatus(): array
{
return $this->trackedEppStatus;
}
public function setTrackedEppStatus(array $trackedEppStatus): static
{
$this->trackedEppStatus = $trackedEppStatus;
return $this;
}
public function isEnabled(): ?bool
{
return $this->enabled;
}
public function setEnabled(bool $enabled): static
{
$this->enabled = $enabled;
return $this;
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Exception;
class DomainNotFoundException extends \Exception
{
public static function fromDomain(string $ldhName): self
{
return new self("The domain name $ldhName is not present in the WHOIS database");
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Exception;
class MalformedDomainException extends \Exception
{
public static function fromDomain(string $ldhName): self
{
return new self("Malformed domain name ($ldhName)");
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Exception\Provider;
abstract class AbstractProviderException extends \Exception
{
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Exception\Provider;
class DomainOrderFailedExeption extends AbstractProviderException
{
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Exception\Provider;
class EppContactIsAvailableException extends AbstractProviderException
{
public static function fromContact(string $handle): self
{
return new self("At least one of the entered contacts cannot be used because it is indicated as available ($handle)");
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Exception\Provider;
class ExpiredLoginException extends AbstractProviderException
{
public static function fromIdentifier(string $identifier): self
{
return new self("Expired login for identifier $identifier");
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Exception\Provider;
class InvalidLoginException extends AbstractProviderException
{
public function __construct(string $message = '')
{
parent::__construct('' === $message ? 'The status of these credentials is not valid' : $message);
}
public static function fromIdentifier(string $identifier): self
{
return new self("Invalid login for identifier $identifier");
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Exception\Provider;
class InvalidLoginStatusException extends AbstractProviderException
{
public static function fromStatus(string $status): self
{
return new self("The status of these credentials is not valid ($status)");
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Exception\Provider;
class NamecheapRequiresAddressException extends AbstractProviderException
{
public function __construct()
{
parent::__construct('Namecheap account requires at least one address to purchase a domain');
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Exception\Provider;
class PermissionErrorException extends AbstractProviderException
{
public static function fromIdentifier(string $identifier): self
{
return new self("Not enough permissions for identifier $identifier");
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Exception\Provider;
class ProviderGenericErrorException extends AbstractProviderException
{
public function __construct(string $message)
{
parent::__construct($message);
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Exception\Provider;
class UserNoExplicitConsentException extends AbstractProviderException
{
public function __construct()
{
parent::__construct('The user has not given explicit consent');
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Exception;
class TldNotSupportedException extends \Exception
{
public static function fromTld(string $tld): self
{
return new self("The requested TLD $tld is not yet supported, please try again with another one");
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Exception;
class UnknownRdapServerException extends \Exception
{
public static function fromTld(string $tld): self
{
return new self("TLD $tld: Unable to determine which RDAP server to contact");
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Exception;
class UnsupportedDsnSchemeException extends \Exception
{
public static function fromScheme(string $scheme): UnsupportedDsnSchemeException
{
return new UnsupportedDsnSchemeException("The DSN scheme ($scheme) is not supported");
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Factory;
use App\Entity\User;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Zenstruck\Foundry\Persistence\PersistentObjectFactory;
/**
* @extends PersistentObjectFactory<User>
*/
final class UserFactory extends PersistentObjectFactory
{
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
*
* @todo inject services if required
*/
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
) {
}
#[\Override]
public static function class(): string
{
return User::class;
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
*
* @todo add your default values here
*/
#[\Override]
protected function defaults(): array|callable
{
$createdAt = \DateTimeImmutable::createFromMutable(self::faker()->dateTime());
$plainPassword = self::faker()->password(16, 20);
return [
'createdAt' => $createdAt,
'verifiedAt' => $createdAt,
'email' => self::faker()->unique()->safeEmail(),
'plainPassword' => $plainPassword,
'roles' => ['ROLE_USER'],
];
}
/**
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
*/
#[\Override]
protected function initialize(): static
{
return $this->afterInstantiate(function (User $user): void {
if ($user->getPlainPassword()) {
$user->setPassword(
$this->passwordHasher->hashPassword($user, $user->getPlainPassword())
);
}
});
}
}

View File

@@ -5,7 +5,7 @@ namespace App\Message;
final class OrderDomain
{
public function __construct(
public string $watchListToken,
public string $watchlistToken,
public string $ldhName,
) {
}

View File

@@ -2,7 +2,7 @@
namespace App\Message;
final class ProcessWatchListsTrigger
final class ProcessWatchlistTrigger
{
/*
* Add whatever properties and methods you need

View File

@@ -5,7 +5,7 @@ namespace App\Message;
final class SendDomainEventNotif
{
public function __construct(
public string $watchListToken,
public string $watchlistToken,
public string $ldhName,
public \DateTimeImmutable $updatedAt,
) {

View File

@@ -5,7 +5,7 @@ namespace App\Message;
final readonly class UpdateDomainsFromWatchlist
{
public function __construct(
public string $watchListToken,
public string $watchlistToken,
) {
}
}

View File

@@ -3,15 +3,15 @@
namespace App\MessageHandler;
use App\Entity\Domain;
use App\Entity\WatchList;
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\Repository\WatchlistRepository;
use App\Service\ChatNotificationService;
use App\Service\Connector\AbstractProvider;
use App\Service\InfluxdbService;
use App\Service\Provider\AbstractProvider;
use App\Service\StatService;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -31,7 +31,7 @@ final readonly class OrderDomainHandler
public function __construct(
string $mailerSenderEmail,
string $mailerSenderName,
private WatchListRepository $watchListRepository,
private WatchlistRepository $watchlistRepository,
private DomainRepository $domainRepository,
private KernelInterface $kernel,
private MailerInterface $mailer,
@@ -54,12 +54,12 @@ final readonly class OrderDomainHandler
*/
public function __invoke(OrderDomain $message): void
{
/** @var WatchList $watchList */
$watchList = $this->watchListRepository->findOneBy(['token' => $message->watchListToken]);
/** @var Watchlist $watchlist */
$watchlist = $this->watchlistRepository->findOneBy(['token' => $message->watchlistToken]);
/** @var Domain $domain */
$domain = $this->domainRepository->findOneBy(['ldhName' => $message->ldhName]);
$connector = $watchList->getConnector();
$connector = $watchlist->getConnector();
/*
* We make sure that the domain name is marked absent from WHOIS in the database before continuing.
@@ -71,8 +71,8 @@ final readonly class OrderDomainHandler
return;
}
$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,
$this->logger->notice('Watchlist is linked to a connector : a purchase attempt will be made for this domain name', [
'watchlist' => $message->watchlistToken,
'connector' => $connector->getId(),
'ldhName' => $message->ldhName,
'provider' => $connector->getProvider()->value,
@@ -93,14 +93,15 @@ final readonly class OrderDomainHandler
* The user is authenticated to ensure that the credentials are still valid.
* If no errors occur, the purchase is attempted.
*/
$connectorProvider->authenticate($connector->getAuthData());
$connectorProvider->orderDomain($domain, $this->kernel->isDebug());
/*
* If the purchase was successful, the statistics are updated and a success message is sent to the user.
*/
$this->logger->notice('Watchlist {watchlist} is linked to connector {connector}. A purchase was successfully made for domain {ldhName} with provider {provider}.', [
'watchlist' => $message->watchListToken,
$this->logger->notice('Watchlist is linked to connector : a purchase was successfully made for this domain name', [
'watchlist' => $message->watchlistToken,
'connector' => $connector->getId(),
'ldhName' => $message->ldhName,
'provider' => $connector->getProvider()->value,
@@ -111,15 +112,18 @@ final readonly class OrderDomainHandler
$this->influxdbService->addDomainOrderPoint($connector, $domain, true);
}
$notification = (new DomainOrderNotification($this->sender, $domain, $connector));
$this->mailer->send($notification->asEmailMessage(new Recipient($watchList->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchList, $notification);
$this->mailer->send($notification->asEmailMessage(new Recipient($watchlist->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchlist, $notification);
} catch (\Throwable $exception) {
/*
* The purchase was not successful (for several possible reasons that we have not determined).
* The user is informed and the exception is raised, which may allow you to try again.
*/
$this->logger->warning('Unable to complete purchase. An error message is sent to user {username}.', [
'username' => $watchList->getUser()->getUserIdentifier(),
$this->logger->warning('Unable to complete purchase : an error message is sent to the user', [
'watchlist' => $message->watchlistToken,
'connector' => $connector->getId(),
'ldhName' => $message->ldhName,
'provider' => $connector->getProvider()->value,
]);
$this->statService->incrementStat('stats.domain.purchase.failed');
@@ -127,8 +131,8 @@ final readonly class OrderDomainHandler
$this->influxdbService->addDomainOrderPoint($connector, $domain, false);
}
$notification = (new DomainOrderErrorNotification($this->sender, $domain));
$this->mailer->send($notification->asEmailMessage(new Recipient($watchList->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchList, $notification);
$this->mailer->send($notification->asEmailMessage(new Recipient($watchlist->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchlist, $notification);
throw $exception;
}

View File

@@ -2,20 +2,20 @@
namespace App\MessageHandler;
use App\Entity\WatchList;
use App\Message\ProcessWatchListsTrigger;
use App\Entity\Watchlist;
use App\Message\ProcessWatchlistTrigger;
use App\Message\UpdateDomainsFromWatchlist;
use App\Repository\WatchListRepository;
use App\Repository\WatchlistRepository;
use Random\Randomizer;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler]
final readonly class ProcessWatchListsTriggerHandler
final readonly class ProcessWatchlistTriggerHandler
{
public function __construct(
private WatchListRepository $watchListRepository,
private WatchlistRepository $watchlistRepository,
private MessageBusInterface $bus,
) {
}
@@ -23,7 +23,7 @@ final readonly class ProcessWatchListsTriggerHandler
/**
* @throws ExceptionInterface
*/
public function __invoke(ProcessWatchListsTrigger $message): void
public function __invoke(ProcessWatchlistTrigger $message): void
{
/*
* We shuffle the watch lists to process them in an order that we consider random.
@@ -31,11 +31,11 @@ final readonly class ProcessWatchListsTriggerHandler
*/
$randomizer = new Randomizer();
$watchLists = $randomizer->shuffleArray($this->watchListRepository->findAll());
$watchlists = $randomizer->shuffleArray($this->watchlistRepository->getEnabledWatchlist());
/** @var WatchList $watchList */
foreach ($watchLists as $watchList) {
$this->bus->dispatch(new UpdateDomainsFromWatchlist($watchList->getToken()));
/** @var Watchlist $watchlist */
foreach ($watchlists as $watchlist) {
$this->bus->dispatch(new UpdateDomainsFromWatchlist($watchlist->getToken()));
}
}
}

View File

@@ -2,15 +2,17 @@
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\Entity\DomainStatus;
use App\Entity\Watchlist;
use App\Message\SendDomainEventNotif;
use App\Notifier\DomainStatusUpdateNotification;
use App\Notifier\DomainUpdateNotification;
use App\Repository\DomainEventRepository;
use App\Repository\DomainRepository;
use App\Repository\WatchListRepository;
use App\Repository\DomainStatusRepository;
use App\Repository\WatchlistRepository;
use App\Service\ChatNotificationService;
use App\Service\InfluxdbService;
use App\Service\StatService;
@@ -19,7 +21,6 @@ use Symfony\Component\DependencyInjection\Attribute\Autowire;
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;
@@ -35,11 +36,13 @@ final readonly class SendDomainEventNotifHandler
private MailerInterface $mailer,
private StatService $statService,
private DomainRepository $domainRepository,
private WatchListRepository $watchListRepository,
private WatchlistRepository $watchlistRepository,
private ChatNotificationService $chatNotificationService,
#[Autowire(param: 'influxdb_enabled')]
private bool $influxdbEnabled,
private InfluxdbService $influxdbService,
private DomainEventRepository $domainEventRepository,
private DomainStatusRepository $domainStatusRepository,
) {
$this->sender = new Address($mailerSenderEmail, $mailerSenderName);
}
@@ -47,56 +50,99 @@ final readonly class SendDomainEventNotifHandler
/**
* @throws TransportExceptionInterface
* @throws \Exception
* @throws ExceptionInterface
*/
public function __invoke(SendDomainEventNotif $message): void
{
/** @var WatchList $watchList */
$watchList = $this->watchListRepository->findOneBy(['token' => $message->watchListToken]);
/** @var Watchlist $watchlist */
$watchlist = $this->watchlistRepository->findOneBy(['token' => $message->watchlistToken]);
/** @var Domain $domain */
$domain = $this->domainRepository->findOneBy(['ldhName' => $message->ldhName]);
$recipient = new Recipient($watchlist->getUser()->getEmail());
/*
* For each new event whose date is after the domain name update date (before the current domain name update)
*/
/** @var DomainEvent $event */
foreach ($domain->getEvents()->filter(
fn ($event) => $message->updatedAt < $event->getDate() && $event->getDate() < new \DateTimeImmutable()) as $event
) {
$watchListTriggers = $watchList->getWatchListTriggers()
->filter(fn ($trigger) => $trigger->getEvent() === $event->getAction());
/** @var DomainEvent[] $newEvents */
$newEvents = $this->domainEventRepository->findNewDomainEvents($domain, $message->updatedAt);
/*
* For each trigger, we perform the appropriate action: send email or send push notification (for now)
*/
foreach ($newEvents as $event) {
if (!in_array($event->getAction(), $watchlist->getTrackedEvents())) {
continue;
}
/** @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}.', [
$notification = new DomainUpdateNotification($this->sender, $event);
$this->logger->info('New action has been detected on this domain name : an email is sent to user', [
'event' => $event->getAction(),
'ldhName' => $message->ldhName,
'username' => $watchlist->getUser()->getUserIdentifier(),
]);
$this->mailer->send($notification->asEmailMessage($recipient)->getMessage());
if ($this->influxdbEnabled) {
$this->influxdbService->addDomainNotificationPoint($domain, 'email', true);
}
$webhookDsn = $watchlist->getWebhookDsn();
if (null !== $webhookDsn && 0 !== count($webhookDsn)) {
$this->logger->info('New action has been detected on this domain name : a notification is sent to user', [
'event' => $event->getAction(),
'ldhName' => $message->ldhName,
'username' => $watchList->getUser()->getUserIdentifier(),
'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()) {
$webhookDsn = $watchList->getWebhookDsn();
if (null === $webhookDsn || 0 === count($webhookDsn)) {
continue;
}
$this->chatNotificationService->sendChatNotification($watchList, $notification);
$this->chatNotificationService->sendChatNotification($watchlist, $notification);
if ($this->influxdbEnabled) {
$this->influxdbService->addDomainNotificationPoint($domain, 'chat', true);
}
}
$this->statService->incrementStat('stats.alert.sent');
}
/** @var DomainStatus $domainStatus */
$domainStatus = $this->domainStatusRepository->findNewDomainStatus($domain, $message->updatedAt);
if (null !== $domainStatus && count(array_intersect(
$watchlist->getTrackedEppStatus(),
[...$domainStatus->getAddStatus(), ...$domainStatus->getDeleteStatus()]
))) {
$notification = new DomainStatusUpdateNotification($this->sender, $domain, $domainStatus);
$this->logger->info('New domain status has been detected on this domain name : an email is sent to user', [
'addStatus' => $domainStatus->getAddStatus(),
'deleteStatus' => $domainStatus->getDeleteStatus(),
'status' => $domain->getStatus(),
'ldhName' => $message->ldhName,
'username' => $watchlist->getUser()->getUserIdentifier(),
]);
$this->mailer->send($notification->asEmailMessage($recipient)->getMessage());
if ($this->influxdbEnabled) {
$this->influxdbService->addDomainNotificationPoint($domain, 'email', true);
}
$webhookDsn = $watchlist->getWebhookDsn();
if (null !== $webhookDsn && 0 !== count($webhookDsn)) {
$this->logger->info('New domain status has been detected on this domain name : a notification is sent to user', [
'addStatus' => $domainStatus->getAddStatus(),
'deleteStatus' => $domainStatus->getDeleteStatus(),
'status' => $domain->getStatus(),
'ldhName' => $message->ldhName,
'username' => $watchlist->getUser()->getUserIdentifier(),
]);
$this->chatNotificationService->sendChatNotification($watchlist, $notification);
if ($this->influxdbEnabled) {
$this->influxdbService->addDomainNotificationPoint($domain, TriggerAction::SendChat, true);
$this->influxdbService->addDomainNotificationPoint($domain, 'chat', true);
}
$this->statService->incrementStat('stats.alert.sent');
}
$this->statService->incrementStat('stats.alert.sent');
}
}
}

View File

@@ -3,21 +3,23 @@
namespace App\MessageHandler;
use App\Entity\Domain;
use App\Entity\WatchList;
use App\Entity\Watchlist;
use App\Exception\DomainNotFoundException;
use App\Exception\TldNotSupportedException;
use App\Exception\UnknownRdapServerException;
use App\Message\OrderDomain;
use App\Message\SendDomainEventNotif;
use App\Message\UpdateDomainsFromWatchlist;
use App\Notifier\DomainDeletedNotification;
use App\Notifier\DomainUpdateErrorNotification;
use App\Repository\WatchListRepository;
use App\Repository\DomainRepository;
use App\Repository\WatchlistRepository;
use App\Service\ChatNotificationService;
use App\Service\Connector\AbstractProvider;
use App\Service\Connector\CheckDomainProviderInterface;
use App\Service\Provider\AbstractProvider;
use App\Service\Provider\CheckDomainProviderInterface;
use App\Service\RDAPService;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
@@ -36,10 +38,11 @@ final readonly class UpdateDomainsFromWatchlistHandler
string $mailerSenderEmail,
string $mailerSenderName,
private MessageBusInterface $bus,
private WatchListRepository $watchListRepository,
private WatchlistRepository $watchlistRepository,
private LoggerInterface $logger,
#[Autowire(service: 'service_container')]
private ContainerInterface $locator,
private DomainRepository $domainRepository,
) {
$this->sender = new Address($mailerSenderEmail, $mailerSenderName);
}
@@ -49,36 +52,37 @@ final readonly class UpdateDomainsFromWatchlistHandler
*/
public function __invoke(UpdateDomainsFromWatchlist $message): void
{
/** @var WatchList $watchList */
$watchList = $this->watchListRepository->findOneBy(['token' => $message->watchListToken]);
/** @var Watchlist $watchlist */
$watchlist = $this->watchlistRepository->findOneBy(['token' => $message->watchlistToken]);
$this->logger->info('Domain names from Watchlist {token} will be processed.', [
'token' => $message->watchListToken,
$this->logger->debug('Domain names listed in the Watchlist will be updated', [
'watchlist' => $message->watchlistToken,
]);
/** @var AbstractProvider $connectorProvider */
$connectorProvider = $this->getConnectorProvider($watchList);
$connectorProvider = $this->getConnectorProvider($watchlist);
if ($connectorProvider instanceof CheckDomainProviderInterface) {
$this->logger->notice('Watchlist {watchlist} linked to connector {connector}.', [
'watchlist' => $watchList->getToken(),
'connector' => $watchList->getConnector()->getId(),
$this->logger->debug('Watchlist is linked to a connector', [
'watchlist' => $watchlist->getToken(),
'connector' => $watchlist->getConnector()->getId(),
]);
$domainList = array_unique(array_map(fn (Domain $d) => $d->getLdhName(), $watchlist->getDomains()->toArray()));
try {
$checkedDomains = $connectorProvider->checkDomains(
...array_unique(array_map(fn (Domain $d) => $d->getLdhName(), $watchList->getDomains()->toArray()))
);
$checkedDomains = $connectorProvider->checkDomains(...$domainList);
} catch (\Throwable $exception) {
$this->logger->warning('Unable to check domain names availability with connector {connector}.', [
'connector' => $watchList->getConnector()->getId(),
$this->logger->warning('Unable to check domain names availability with this connector', [
'connector' => $watchlist->getConnector()->getId(),
'ldhName' => $domainList,
]);
throw $exception;
}
foreach ($checkedDomains as $domain) {
$this->bus->dispatch(new OrderDomain($watchList->getToken(), $domain));
$this->bus->dispatch(new OrderDomain($watchlist->getToken(), $domain));
}
return;
@@ -92,9 +96,10 @@ final readonly class UpdateDomainsFromWatchlistHandler
*/
/** @var Domain $domain */
foreach ($watchList->getDomains()->filter(fn ($domain) => $domain->isToBeUpdated(false, null !== $watchList->getConnector())) as $domain
foreach ($watchlist->getDomains()->filter(fn ($domain) => $this->RDAPService->isToBeUpdated($domain, false, null !== $watchlist->getConnector())) as $domain
) {
$updatedAt = $domain->getUpdatedAt();
$deleted = $domain->getDeleted();
try {
/*
@@ -102,42 +107,34 @@ final readonly class UpdateDomainsFromWatchlistHandler
* We send messages that correspond to the sending of notifications that will not be processed here.
*/
$this->RDAPService->registerDomain($domain->getLdhName());
$this->bus->dispatch(new SendDomainEventNotif($watchList->getToken(), $domain->getLdhName(), $updatedAt));
} catch (NotFoundHttpException) {
if (!$domain->getDeleted()) {
$this->bus->dispatch(new SendDomainEventNotif($watchlist->getToken(), $domain->getLdhName(), $updatedAt));
} catch (DomainNotFoundException) {
$newDomain = $this->domainRepository->findOneBy(['ldhName' => $domain->getLdhName()]);
if (!$deleted && null !== $newDomain && $newDomain->getDeleted()) {
$notification = new DomainDeletedNotification($this->sender, $domain);
$this->mailer->send($notification->asEmailMessage(new Recipient($watchList->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchList, $notification);
$this->mailer->send($notification->asEmailMessage(new Recipient($watchlist->getUser()->getEmail()))->getMessage());
$this->chatNotificationService->sendChatNotification($watchlist, $notification);
}
if ($watchList->getConnector()) {
if ($watchlist->getConnector()) {
/*
* If the domain name no longer appears in the WHOIS AND a connector is associated with this Watchlist,
* this connector is used to purchase the domain name.
*/
$this->bus->dispatch(new OrderDomain($watchList->getToken(), $domain->getLdhName()));
$this->bus->dispatch(new OrderDomain($watchlist->getToken(), $domain->getLdhName()));
}
} catch (\Throwable $e) {
} catch (TldNotSupportedException|UnknownRdapServerException) {
/*
* In case of another unknown error,
* the owner of the Watchlist is informed that an error occurred in updating the domain name.
* In this case, the domain name can no longer be updated. Unfortunately, there is nothing more that can be done.
*/
$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;
}
}
}
private function getConnectorProvider(WatchList $watchList): ?object
private function getConnectorProvider(Watchlist $watchlist): ?object
{
$connector = $watchList->getConnector();
$connector = $watchlist->getConnector();
if (null === $connector || null === $connector->getProvider()) {
return null;
}

View File

@@ -3,7 +3,8 @@
namespace App\MessageHandler;
use App\Message\UpdateRdapServers;
use App\Service\RDAPService;
use App\Repository\DomainRepository;
use App\Service\OfficialDataService;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
@@ -16,8 +17,8 @@ use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
final readonly class UpdateRdapServersHandler
{
public function __construct(
private RDAPService $RDAPService,
private ParameterBagInterface $bag,
private OfficialDataService $officialDataService,
private ParameterBagInterface $bag, private DomainRepository $domainRepository,
) {
}
@@ -39,8 +40,9 @@ final readonly class UpdateRdapServersHandler
*/
try {
$this->RDAPService->updateTldListIANA();
$this->RDAPService->updateGTldListICANN();
$this->officialDataService->updateTldListIANA();
$this->officialDataService->updateGTldListICANN();
$this->domainRepository->setDomainDeletedIfTldIsDeleted();
} catch (\Throwable $throwable) {
$throws[] = $throwable;
}
@@ -50,7 +52,7 @@ final readonly class UpdateRdapServersHandler
*/
try {
$this->RDAPService->updateRDAPServersFromIANA();
$this->officialDataService->updateRDAPServersFromIANA();
} catch (\Throwable $throwable) {
$throws[] = $throwable;
}
@@ -60,13 +62,13 @@ final readonly class UpdateRdapServersHandler
*/
try {
$this->RDAPService->updateRDAPServersFromFile($this->bag->get('custom_rdap_servers_file'));
$this->officialDataService->updateRDAPServersFromFile($this->bag->get('custom_rdap_servers_file'));
} catch (\Throwable $throwable) {
$throws[] = $throwable;
}
try {
$this->RDAPService->updateRegistrarListIANA();
$this->officialDataService->updateRegistrarListIANA();
} catch (\Throwable $throwable) {
$throws[] = $throwable;
}

View File

@@ -5,7 +5,7 @@ namespace App\MessageHandler;
use App\Message\ValidateConnectorCredentials;
use App\Notifier\ValidateConnectorCredentialsErrorNotification;
use App\Repository\ConnectorRepository;
use App\Service\Connector\AbstractProvider;
use App\Service\Provider\AbstractProvider;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Notifier;
use App\Entity\Domain;
use App\Entity\DomainStatus;
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 DomainStatusUpdateNotification extends DomainWatchdogNotification
{
public function __construct(
private readonly Address $sender,
private readonly Domain $domain,
private readonly DomainStatus $domainStatus,
) {
parent::__construct();
}
public function asChatMessage(?RecipientInterface $recipient = null, ?string $transport = null): ?ChatMessage
{
$ldhName = $this->domain->getLdhName();
$this->subject("Domain EPP status changed $ldhName")
->content("Domain name $ldhName EPP status has been updated.")
->importance(Notification::IMPORTANCE_HIGH);
return ChatMessage::fromNotification($this);
}
public function asPushMessage(?RecipientInterface $recipient = null, ?string $transport = null): ?PushMessage
{
$ldhName = $this->domain->getLdhName();
$this->subject("Domain EPP status changed $ldhName")
->content("Domain name $ldhName EPP status has been updated.")
->importance(Notification::IMPORTANCE_HIGH);
return PushMessage::fromNotification($this);
}
public function asEmailMessage(EmailRecipientInterface $recipient): EmailMessage
{
$ldhName = $this->domain->getLdhName();
$email = (new TemplatedEmail())
->from($this->sender)
->to($recipient->getEmail())
->priority(Email::PRIORITY_HIGH)
->subject("Domain EPP status changed $ldhName")
->htmlTemplate('emails/success/domain_status_updated.html.twig')
->locale('en')
->context([
'domain' => $this->domain,
'domainStatus' => $this->domainStatus,
]);
$email->getHeaders()
->addTextHeader('In-Reply-To', "<$ldhName+updated@domain-watchdog>")
->addTextHeader('References', "<$ldhName+updated@domain-watchdog>");
return new EmailMessage($email);
}
}

View File

@@ -27,7 +27,7 @@ class DomainUpdateNotification extends DomainWatchdogNotification
$ldhName = $this->domainEvent->getDomain()->getLdhName();
$action = $this->domainEvent->getAction();
$this->subject("Domain changed $ldhName ($action)")
->content("Domain name $ldhName information has been updated ($action).")
->content("Domain name $ldhName events has been updated ($action).")
->importance(Notification::IMPORTANCE_HIGH);
return ChatMessage::fromNotification($this);
@@ -38,7 +38,7 @@ class DomainUpdateNotification extends DomainWatchdogNotification
$ldhName = $this->domainEvent->getDomain()->getLdhName();
$action = $this->domainEvent->getAction();
$this->subject("Domain changed $ldhName ($action)")
->content("Domain name $ldhName information has been updated ($action).")
->content("Domain name $ldhName events has been updated ($action).")
->importance(Notification::IMPORTANCE_HIGH);
return PushMessage::fromNotification($this);
@@ -52,8 +52,8 @@ class DomainUpdateNotification extends DomainWatchdogNotification
->from($this->sender)
->to($recipient->getEmail())
->priority(Email::PRIORITY_HIGH)
->subject("Domain name $ldhName information has been updated")
->htmlTemplate('emails/success/domain_updated.html.twig')
->subject("Domain name $ldhName events has been updated")
->htmlTemplate('emails/success/domain_event_updated.html.twig')
->locale('en')
->context([
'event' => $this->domainEvent,

View File

@@ -2,6 +2,7 @@
namespace App\Repository;
use App\Entity\Domain;
use App\Entity\DomainEntity;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -16,6 +17,18 @@ class DomainEntityRepository extends ServiceEntityRepository
parent::__construct($registry, DomainEntity::class);
}
public function setDomainEntityAsDeleted(Domain $domain)
{
return $this->createQueryBuilder('de')
->update()
->set('de.deletedAt', ':now')
->where('de.domain = :domain')
->andWhere('de.deletedAt IS NULL')
->setParameter('now', new \DateTimeImmutable())
->setParameter('domain', $domain)
->getQuery()->execute();
}
// /**
// * @return DomainEntity[] Returns an array of DomainEntity objects
// */

View File

@@ -2,6 +2,7 @@
namespace App\Repository;
use App\Entity\Domain;
use App\Entity\DomainEvent;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -16,6 +17,46 @@ class DomainEventRepository extends ServiceEntityRepository
parent::__construct($registry, DomainEvent::class);
}
public function findLastDomainEvent(Domain $domain, string $action)
{
return $this->createQueryBuilder('de')
->select()
->where('de.domain = :domain')
->andWhere('de.action = :action')
->andWhere('de.deleted = FALSE')
->orderBy('de.date', 'DESC')
->setMaxResults(1)
->getQuery()
->setParameter('domain', $domain)
->setParameter('action', $action)
->getOneOrNullResult();
}
public function findNewDomainEvents(Domain $domain, \DateTimeImmutable $updatedAt)
{
return $this->createQueryBuilder('de')
->select()
->where('de.domain = :domain')
->andWhere('de.date > :updatedAt')
->andWhere('de.date < :now')
->setParameter('domain', $domain)
->setParameter('updatedAt', $updatedAt)
->setParameter('now', new \DateTimeImmutable())
->getQuery()->getResult();
}
public function setDomainEventAsDeleted(Domain $domain)
{
return $this->createQueryBuilder('de')
->update()
->set('de.deleted', ':deleted')
->where('de.domain = :domain')
->setParameter('deleted', true)
->setParameter('domain', $domain)
->getQuery()
->execute();
}
// /**
// * @return DomainEvent[] Returns an array of DomainEvent objects
// */

View File

@@ -3,6 +3,8 @@
namespace App\Repository;
use App\Entity\Domain;
use App\Entity\Tld;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -16,6 +18,51 @@ class DomainRepository extends ServiceEntityRepository
parent::__construct($registry, Domain::class);
}
public function findByTld(string $tld): array
{
return $this->createQueryBuilder('d')
->addSelect('events')
->leftJoin('d.events', 'events')
->where('d.tld = :dot')
->setParameter('dot', $tld)
->getQuery()
->getResult();
}
public function getActiveDomainCountByTld(): array
{
return $this->createQueryBuilder('d')
->select('t.tld tld')
->join('d.tld', 't')
->addSelect('COUNT(d.ldhName) AS domain')
->addGroupBy('t.tld')
->where('d.deleted = FALSE')
->orderBy('domain', 'DESC')
->setMaxResults(5)
->getQuery()->getArrayResult();
}
public function setDomainDeletedIfTldIsDeleted()
{
return $this->createQueryBuilder('d')
->update()
->set('d.deleted', ':deleted')
->where('d.tld IN (SELECT t FROM '.Tld::class.' t WHERE t.deletedAt IS NOT NULL)')
->setParameter('deleted', true)
->getQuery()->execute();
}
public function getMyTrackedDomains(User $user): array
{
return $this->createQueryBuilder('d')
->join('d.watchlists', 'w')
->where('w.user = :user')
->andWhere('d.deleted = false')
->setParameter('user', $user)
->getQuery()
->getResult();
}
// /**
// * @return Domain[] Returns an array of Domain objects
// */

View File

@@ -2,6 +2,7 @@
namespace App\Repository;
use App\Entity\Domain;
use App\Entity\DomainStatus;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
@@ -16,6 +17,31 @@ class DomainStatusRepository extends ServiceEntityRepository
parent::__construct($registry, DomainStatus::class);
}
public function findNewDomainStatus(Domain $domain, \DateTimeImmutable $updatedAt): ?DomainStatus
{
return $this->createQueryBuilder('ds')
->select()
->where('ds.domain = :domain')
->andWhere('ds.date = :date')
->orderBy('ds.createdAt', 'DESC')
->setParameter('domain', $domain)
->setParameter('date', $updatedAt)
->getQuery()
->getOneOrNullResult();
}
public function findLastDomainStatus(Domain $domain): ?DomainStatus
{
return $this->createQueryBuilder('ds')
->select()
->where('ds.domain = :domain')
->setParameter('domain', $domain)
->orderBy('ds.createdAt', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
// /**
// * @return DomainStatus[] Returns an array of DomainStatus objects
// */

View File

@@ -2,39 +2,39 @@
namespace App\Repository;
use App\Entity\WatchListTrigger;
use App\Entity\IcannAccreditation;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<WatchListTrigger>
* @extends ServiceEntityRepository<IcannAccreditation>
*/
class EventTriggerRepository extends ServiceEntityRepository
class IcannAccreditationRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, WatchListTrigger::class);
parent::__construct($registry, IcannAccreditation::class);
}
// /**
// * @return WatchListTrigger[] Returns an array of WatchListTrigger objects
// * @return Connector[] Returns an array of Connector objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('e')
// ->andWhere('e.exampleField = :val')
// return $this->createQueryBuilder('c')
// ->andWhere('c.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('e.id', 'ASC')
// ->orderBy('c.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?WatchListTrigger
// public function findOneBySomeField($value): ?Connector
// {
// return $this->createQueryBuilder('e')
// ->andWhere('e.exampleField = :val')
// return $this->createQueryBuilder('c')
// ->andWhere('c.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()

View File

@@ -16,6 +16,28 @@ class TldRepository extends ServiceEntityRepository
parent::__construct($registry, Tld::class);
}
/**
* @return Tld[] Returns an array of deleted Tld
*/
public function findDeleted(): array
{
return $this->createQueryBuilder('t')
->andWhere('t.deletedAt IS NOT NULL')
->orderBy('t.deletedAt', 'DESC')
->getQuery()
->getResult();
}
public function setAllTldAsDeleted()
{
return $this->createQueryBuilder('t')
->update()
->set('t.deletedAt', 'COALESCE(t.removalDate, CURRENT_TIMESTAMP())')
->where('t.tld != :dot')
->setParameter('dot', '.')
->getQuery()->execute();
}
// /**
// * @return Tld[] Returns an array of Tld objects
// */

View File

@@ -2,22 +2,39 @@
namespace App\Repository;
use App\Entity\WatchList;
use App\Entity\Watchlist;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<WatchList>
* @extends ServiceEntityRepository<Watchlist>
*/
class WatchListRepository extends ServiceEntityRepository
class WatchlistRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, WatchList::class);
parent::__construct($registry, Watchlist::class);
}
public function getTrackedDomainCount()
{
return $this->createQueryBuilder('w')
->select('COUNT(DISTINCT d.ldhName)')
->join('w.domains', 'd')
->where('d.deleted = FALSE')
->getQuery()->getSingleScalarResult();
}
public function getEnabledWatchlist()
{
return $this->createQueryBuilder('w')
->select()
->where('w.enabled = true')
->getQuery()->execute();
}
// /**
// * @return WatchList[] Returns an array of WatchList objects
// * @return Watchlist[] Returns an array of Watchlist objects
// */
// public function findByExampleField($value): array
// {
@@ -31,7 +48,7 @@ class WatchListRepository extends ServiceEntityRepository
// ;
// }
// public function findOneBySomeField($value): ?WatchList
// public function findOneBySomeField($value): ?Watchlist
// {
// return $this->createQueryBuilder('b')
// ->andWhere('b.exampleField = :val')

28
src/Schedule.php Normal file
View File

@@ -0,0 +1,28 @@
<?php
namespace App;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\Schedule as SymfonySchedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
#[AsSchedule]
class Schedule implements ScheduleProviderInterface
{
public function __construct(
private CacheInterface $cache,
) {
}
public function getSchedule(): SymfonySchedule
{
return (new SymfonySchedule())
->stateful($this->cache) // ensure missed tasks are executed
->processOnlyLastMissedRun(true) // ensure only last missed task is run
// add your own tasks here
// see https://symfony.com/doc/current/scheduler.html#attaching-recurring-messages-to-a-schedule
;
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Scheduler;
use App\Message\ProcessWatchListsTrigger;
use App\Message\ProcessWatchlistTrigger;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
@@ -10,7 +10,7 @@ use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Contracts\Cache\CacheInterface;
#[AsSchedule('notif_watchlist')]
final readonly class SendNotifWatchListTriggerSchedule implements ScheduleProviderInterface
final readonly class SendNotifWatchlistTriggerSchedule implements ScheduleProviderInterface
{
public function __construct(
private CacheInterface $cache,
@@ -21,7 +21,7 @@ final readonly class SendNotifWatchListTriggerSchedule implements ScheduleProvid
{
return (new Schedule())
->add(
RecurringMessage::every('5 minutes', new ProcessWatchListsTrigger()),
RecurringMessage::every('5 minutes', new ProcessWatchlistTrigger()),
)
->stateful($this->cache);
}

View File

@@ -47,7 +47,7 @@ readonly class EmailVerifier
{
$this->verifyEmailHelper->validateEmailConfirmationFromRequest($request, (string) $user->getId(), $user->getEmail());
$user->setVerified(true);
$user->setVerifiedAt(new \DateTimeImmutable());
$this->entityManager->persist($user);
$this->entityManager->flush();

View File

@@ -33,7 +33,7 @@ class JWTAuthenticator implements AuthenticationSuccessHandlerInterface
public function handleAuthenticationSuccess(UserInterface $user, $jwt = null): Response
{
if (($user instanceof User) && !$user->isVerified()) {
if (($user instanceof User) && null === $user->getVerifiedAt()) {
throw new AccessDeniedHttpException('You have not yet validated your email address.');
}

View File

@@ -57,8 +57,8 @@ class OAuthAuthenticator extends OAuth2Authenticator implements AuthenticationEn
return $existingUser;
}
$user = new User();
$user->setEmail($userFromToken->getEmail())->setVerified(true);
$user = (new User())->setCreatedAt(new \DateTimeImmutable());
$user->setEmail($userFromToken->getEmail())->setVerifiedAt($user->getCreatedAt());
$this->em->persist($user);
$this->em->flush();

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Service;
use App\Entity\Domain;
use App\Entity\DomainEntity;
use App\Entity\DomainEvent;
use Eluceo\iCal\Domain\Entity\Attendee;
use Eluceo\iCal\Domain\Entity\Event;
use Eluceo\iCal\Domain\Enum\EventStatus;
use Eluceo\iCal\Domain\ValueObject\Category;
use Eluceo\iCal\Domain\ValueObject\Date;
use Eluceo\iCal\Domain\ValueObject\EmailAddress;
use Eluceo\iCal\Domain\ValueObject\SingleDay;
use Eluceo\iCal\Domain\ValueObject\Timestamp;
use Sabre\VObject\EofException;
use Sabre\VObject\InvalidDataException;
use Sabre\VObject\ParseException;
use Sabre\VObject\Reader;
readonly class CalendarService
{
public function __construct(
private RDAPService $RDAPService,
) {
}
/**
* @return Event[]
*
* @throws ParseException
* @throws EofException
* @throws InvalidDataException
* @throws \Exception
*/
public function getDomainCalendarEvents(Domain $domain): array
{
$events = [];
$attendees = [];
/* @var DomainEntity $entity */
foreach ($domain->getDomainEntities()->filter(fn (DomainEntity $domainEntity) => !$domainEntity->getDeletedAt())->getIterator() as $domainEntity) {
$jCard = $domainEntity->getEntity()->getJCard();
if (empty($jCard)) {
continue;
}
$vCardData = Reader::readJson($jCard);
if (empty($vCardData->EMAIL) || empty($vCardData->FN)) {
continue;
}
$email = (string) $vCardData->EMAIL;
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
continue;
}
$attendees[] = (new Attendee(new EmailAddress($email)))->setDisplayName((string) $vCardData->FN);
}
/** @var DomainEvent $event */
foreach ($domain->getEvents()->filter(fn (DomainEvent $e) => $e->getDate()->diff(new \DateTimeImmutable('now'))->y <= 10)->getIterator() as $event) {
$events[] = (new Event())
->setLastModified(new Timestamp($domain->getUpdatedAt()))
->setStatus(EventStatus::CONFIRMED())
->setSummary($domain->getLdhName().': '.$event->getAction())
->addCategory(new Category($event->getAction()))
->setAttendees($attendees)
->setOccurrence(new SingleDay(new Date($event->getDate()))
);
}
$expiresInDays = $this->RDAPService->getExpiresInDays($domain);
if (null !== $expiresInDays) {
$events[] = (new Event())
->setLastModified(new Timestamp($domain->getUpdatedAt()))
->setStatus(EventStatus::CONFIRMED())
->setSummary($domain->getLdhName().': estimated WHOIS release date')
->addCategory(new Category('release'))
->setAttendees($attendees)
->setOccurrence(new SingleDay(new Date(
(new \DateTimeImmutable())->setTime(0, 0)->add(new \DateInterval('P'.$expiresInDays.'D'))
))
);
}
return $events;
}
}

View File

@@ -3,11 +3,10 @@
namespace App\Service;
use App\Config\WebhookScheme;
use App\Entity\WatchList;
use App\Entity\Watchlist;
use App\Exception\UnsupportedDsnSchemeException;
use App\Notifier\DomainWatchdogNotification;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Notifier\Exception\InvalidArgumentException;
use Symfony\Component\Notifier\Exception\TransportExceptionInterface;
use Symfony\Component\Notifier\Recipient\NoRecipient;
use Symfony\Component\Notifier\Transport\AbstractTransportFactory;
use Symfony\Component\Notifier\Transport\Dsn;
@@ -15,30 +14,29 @@ use Symfony\Component\Notifier\Transport\Dsn;
readonly class ChatNotificationService
{
public function __construct(
private LoggerInterface $logger,
) {
}
public function sendChatNotification(WatchList $watchList, DomainWatchdogNotification $notification): void
/**
* @throws UnsupportedDsnSchemeException
* @throws TransportExceptionInterface
*/
public function sendChatNotification(Watchlist $watchlist, DomainWatchdogNotification $notification): void
{
$webhookDsn = $watchList->getWebhookDsn();
$webhookDsn = $watchlist->getWebhookDsn();
if (empty($webhookDsn)) {
return;
}
foreach ($webhookDsn as $dsnString) {
try {
$dsn = new Dsn($dsnString);
} catch (InvalidArgumentException $exception) {
throw new BadRequestHttpException($exception->getMessage());
}
$dsn = new Dsn($dsnString);
$scheme = $dsn->getScheme();
$webhookScheme = WebhookScheme::tryFrom($scheme);
if (null === $webhookScheme) {
throw new BadRequestHttpException("The DSN scheme ($scheme) is not supported");
throw new UnsupportedDsnSchemeException($scheme);
}
$transportFactoryClass = $webhookScheme->getChatTransportFactory();
@@ -48,29 +46,14 @@ readonly class ChatNotificationService
$push = $notification->asPushMessage(new NoRecipient());
$chat = $notification->asChatMessage(new NoRecipient(), $webhookScheme->value);
try {
$factory = $transportFactory->create($dsn);
$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());
if ($factory->supports($push)) {
$factory->send($push);
} elseif ($factory->supports($chat)) {
$factory->send($chat);
} else {
throw new \InvalidArgumentException('Unsupported message type');
}
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Service;
use App\Config\TriggerAction;
use App\Entity\Connector;
use App\Entity\Domain;
use App\Entity\RdapServer;
@@ -53,6 +52,7 @@ readonly class InfluxdbService
], (int) floor($info['start_time'] * 1e3),
WritePrecision::MS)
);
$this->client->close();
}
public function addDomainOrderPoint(Connector $connector, Domain $domain, bool $success): void
@@ -64,17 +64,19 @@ readonly class InfluxdbService
], [
'success' => $success,
]));
$this->client->close();
}
public function addDomainNotificationPoint(Domain $domain, TriggerAction $triggerAction, bool $success): void
public function addDomainNotificationPoint(Domain $domain, string $triggerAction, bool $success): void
{
$this->writePoints(new Point('domain_notification', [
'domain' => $domain->getLdhName(),
'tld' => $domain->getTld()->getTld(),
'medium' => $triggerAction->value,
'medium' => $triggerAction,
], [
'success' => $success,
]));
$this->client->close();
}
private function writePoints(Point ...$points): void
@@ -89,5 +91,6 @@ readonly class InfluxdbService
} catch (\Throwable) {
// TODO: Add a retry mechanism if writing fails
}
$this->client->close();
}
}

View File

@@ -0,0 +1,302 @@
<?php
namespace App\Service;
use App\Config\RegistrarStatus;
use App\Config\TldType;
use App\Entity\IcannAccreditation;
use App\Entity\RdapServer;
use App\Entity\Tld;
use App\Repository\IcannAccreditationRepository;
use App\Repository\RdapServerRepository;
use App\Repository\TldRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Yaml\Yaml;
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 OfficialDataService
{
/* @see https://www.iana.org/domains/root/db */
private const ISO_TLD_EXCEPTION = ['ac', 'eu', 'uk', 'su', 'tp'];
private const INFRA_TLD = ['arpa'];
private const SPONSORED_TLD = [
'aero',
'asia',
'cat',
'coop',
'edu',
'gov',
'int',
'jobs',
'mil',
'museum',
'post',
'tel',
'travel',
'xxx',
];
private const TEST_TLD = [
'xn--kgbechtv',
'xn--hgbk6aj7f53bba',
'xn--0zwm56d',
'xn--g6w251d',
'xn--80akhbyknj4f',
'xn--11b5bs3a9aj6g',
'xn--jxalpdlp',
'xn--9t4b11yi5a',
'xn--deba0ad',
'xn--zckzah',
'xn--hlcj6aya9esc7a',
];
private const IANA_REGISTRAR_IDS_URL = 'https://www.iana.org/assignments/registrar-ids/registrar-ids.xml';
private const IANA_RDAP_SERVER_LIST_URL = 'https://data.iana.org/rdap/dns.json';
private const IANA_TLD_LIST_URL = 'https://data.iana.org/TLD/tlds-alpha-by-domain.txt';
private const ICANN_GTLD_LIST_URL = 'https://www.icann.org/resources/registries/gtlds/v2/gtlds.json';
public const DOMAIN_DOT = '.';
public function __construct(
private readonly HttpClientInterface $client,
private readonly RdapServerRepository $rdapServerRepository,
private readonly TldRepository $tldRepository,
private readonly IcannAccreditationRepository $icannAccreditationRepository,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
) {
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
* @throws \Exception
*/
public function updateRDAPServersFromIANA(): void
{
$this->logger->info('Start of update the RDAP server list from IANA');
$dnsRoot = $this->client->request(
'GET', self::IANA_RDAP_SERVER_LIST_URL
)->toArray();
$this->updateRDAPServers($dnsRoot);
}
/**
* @throws \Exception
*/
private function updateRDAPServers(array $dnsRoot): void
{
foreach ($dnsRoot['services'] as $service) {
foreach ($service[0] as $tld) {
if (self::DOMAIN_DOT === $tld && null === $this->tldRepository->findOneBy(['tld' => $tld])) {
$this->em->persist((new Tld())->setTld(self::DOMAIN_DOT)->setType(TldType::root));
$this->em->flush();
}
$tldEntity = $this->tldRepository->findOneBy(['tld' => $tld]);
if (null === $tldEntity) {
$tldEntity = (new Tld())->setTld($tld)->setType(TldType::gTLD);
$this->em->persist($tldEntity);
}
foreach ($service[1] as $rdapServerUrl) {
$server = $this->rdapServerRepository->findOneBy(['tld' => $tldEntity->getTld(), 'url' => $rdapServerUrl]);
if (null === $server) {
$server = new RdapServer();
}
$server
->setTld($tldEntity)
->setUrl($rdapServerUrl)
->setUpdatedAt(new \DateTimeImmutable($dnsRoot['publication'] ?? 'now'));
$this->em->persist($server);
}
}
}
$this->em->flush();
}
/**
* @throws \Exception
*/
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
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
*/
public function updateTldListIANA(): void
{
$this->logger->info('Start of retrieval of the list of TLDs according to IANA');
$tldList = array_map(
fn ($tld) => strtolower($tld),
explode(PHP_EOL,
$this->client->request(
'GET', self::IANA_TLD_LIST_URL
)->getContent()
));
array_shift($tldList);
foreach ($tldList as $tld) {
if ('' === $tld) {
continue;
}
$this->tldRepository->setAllTldAsDeleted();
$tldEntity = $this->tldRepository->findOneBy(['tld' => $tld]);
if (null === $tldEntity) {
$tldEntity = new Tld();
$tldEntity->setTld($tld);
$this->logger->notice('New TLD detected according to IANA', [
'tld' => $tld,
]);
} else {
$this->em->refresh($tldEntity);
}
$type = $this->getTldType($tld);
if (null !== $type) {
$tldEntity->setType($type);
} elseif (null === $tldEntity->isContractTerminated()) { // ICANN managed, must be a ccTLD
$tldEntity->setType(TldType::ccTLD);
} else {
$tldEntity->setType(TldType::gTLD);
}
$tldEntity->setDeletedAt(null);
$this->em->persist($tldEntity);
}
$this->em->flush();
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
* @throws \Exception
*/
public function updateRegistrarListIANA(): void
{
$this->logger->info('Start of retrieval of the list of Registrar IDs according to IANA');
$registrarList = $this->client->request(
'GET', self::IANA_REGISTRAR_IDS_URL
);
$data = new \SimpleXMLElement($registrarList->getContent());
foreach ($data->registry->record as $registrar) {
$icannAcreditation = $this->icannAccreditationRepository->findOneBy(['id' => (int) $registrar->value]);
if (null === $icannAcreditation) {
$icannAcreditation = new IcannAccreditation();
}
$icannAcreditation
->setId((int) $registrar->value)
->setRegistrarName($registrar->name)
->setStatus(RegistrarStatus::from($registrar->status))
->setRdapBaseUrl($registrar->rdapurl->count() ? ($registrar->rdapurl->server) : null)
->setUpdated(null !== $registrar->attributes()->updated ? new \DateTimeImmutable($registrar->attributes()->updated) : null)
->setDate(null !== $registrar->attributes()->date ? new \DateTimeImmutable($registrar->attributes()->date) : null);
$this->em->persist($icannAcreditation);
}
$this->em->flush();
}
private function getTldType(string $tld): ?TldType
{
if (in_array(strtolower($tld), self::ISO_TLD_EXCEPTION)) {
return TldType::ccTLD;
}
if (in_array(strtolower($tld), self::INFRA_TLD)) {
return TldType::iTLD;
}
if (in_array(strtolower($tld), self::SPONSORED_TLD)) {
return TldType::sTLD;
}
if (in_array(strtolower($tld), self::TEST_TLD)) {
return TldType::tTLD;
}
return null;
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
* @throws \Exception
*/
public function updateGTldListICANN(): void
{
$this->logger->info('Start of retrieval of the list of gTLDs according to ICANN');
$gTldList = $this->client->request(
'GET', self::ICANN_GTLD_LIST_URL
)->toArray()['gTLDs'];
foreach ($gTldList as $gTld) {
if ('' === $gTld['gTLD']) {
continue;
}
/** @var Tld|null $gtTldEntity */
$gtTldEntity = $this->tldRepository->findOneBy(['tld' => $gTld['gTLD']]);
if (null === $gtTldEntity) {
$gtTldEntity = new Tld();
$gtTldEntity->setTld($gTld['gTLD'])->setType(TldType::gTLD);
$this->logger->notice('New gTLD detected according to ICANN', [
'tld' => $gTld['gTLD'],
]);
}
$gtTldEntity
->setContractTerminated($gTld['contractTerminated'])
->setRegistryOperator($gTld['registryOperator'])
->setSpecification13($gTld['specification13']);
// NOTICE: sTLDs are listed in ICANN's gTLD list
if (null !== $gTld['removalDate']) {
$gtTldEntity->setRemovalDate(new \DateTimeImmutable($gTld['removalDate']));
}
if (null !== $gTld['delegationDate']) {
$gtTldEntity->setDelegationDate(new \DateTimeImmutable($gTld['delegationDate']));
}
if (null !== $gTld['dateOfContractSignature']) {
$gtTldEntity->setDateOfContractSignature(new \DateTimeImmutable($gTld['dateOfContractSignature']));
}
$this->em->persist($gtTldEntity);
}
$this->em->flush();
}
}

View File

@@ -1,9 +1,10 @@
<?php
namespace App\Service\Connector;
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;
@@ -65,7 +66,7 @@ abstract class AbstractProvider
*
* @return array raw authentication data as supplied by the user
*
* @throws HttpException when the user does not accept the necessary conditions
* @throws UserNoExplicitConsentException when the user does not accept the necessary conditions
*/
private function verifyLegalAuthData(array $authData): array
{
@@ -76,7 +77,7 @@ abstract class AbstractProvider
if (true !== $acceptConditions
|| true !== $ownerLegalAge
|| true !== $waiveRetractationPeriod) {
throw new HttpException(451, 'The user has not given explicit consent');
throw new UserNoExplicitConsentException();
}
return $authData;

View File

@@ -1,17 +1,17 @@
<?php
namespace App\Service\Connector;
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\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
@@ -201,26 +201,23 @@ class AutodnsProvider extends AbstractProvider
/**
* @throws TransportExceptionInterface
* @throws InvalidLoginException
*/
protected function assertAuthentication(): void
{
try {
$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()
);
} catch (\Exception) {
throw new BadRequestHttpException('Invalid Login');
}
$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 new BadRequestHttpException('The status of these credentials is not valid');
throw InvalidLoginException::fromIdentifier($this->authData->username);
}
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Service\Connector;
namespace App\Service\Provider;
interface CheckDomainProviderInterface
{

View File

@@ -1,10 +1,11 @@
<?php
namespace App\Service\Connector;
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;
@@ -18,7 +19,6 @@ use Metaregistrar\EPP\eppHelloRequest;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -57,7 +57,7 @@ class EppClientProvider extends AbstractProvider implements CheckDomainProviderI
$resp = $this->eppClient->request(new eppCheckContactRequest($contacts));
foreach ($resp->getCheckedContacts() as $contact => $available) {
if ($available) {
throw new BadRequestHttpException("At least one of the entered contacts cannot be used because it is indicated as available ($contact).");
throw EppContactIsAvailableException::fromContact($contact);
}
}

View File

@@ -1,17 +1,18 @@
<?php
namespace App\Service\Connector;
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\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
@@ -31,12 +32,14 @@ class GandiProvider extends AbstractProvider
protected DefaultProviderDto $authData;
private const BASE_URL = 'https://api.gandi.net';
private const SANDBOX_BASE_URL = 'https://api.sandbox.gandi.net';
public function __construct(
CacheItemPoolInterface $cacheItemPool,
private readonly HttpClientInterface $client,
DenormalizerInterface&NormalizerInterface $serializer,
ValidatorInterface $validator,
private readonly KernelInterface $kernel,
) {
parent::__construct($cacheItemPool, $serializer, $validator);
}
@@ -58,14 +61,14 @@ class GandiProvider extends AbstractProvider
$user = $this->client->request('GET', '/v5/organization/user-info', (new HttpOptions())
->setAuthBearer($this->authData->token)
->setHeader('Accept', 'application/json')
->setBaseUri(self::BASE_URL)
->setBaseUri($this->kernel->isDebug() ? self::SANDBOX_BASE_URL : self::BASE_URL)
->toArray()
)->toArray();
$httpOptions = (new HttpOptions())
->setAuthBearer($this->authData->token)
->setHeader('Accept', 'application/json')
->setBaseUri(self::BASE_URL)
->setBaseUri($this->kernel->isDebug() ? self::SANDBOX_BASE_URL : self::BASE_URL)
->setHeader('Dry-Run', $dryRun ? '1' : '0')
->setJson([
'fqdn' => $ldhName,
@@ -90,28 +93,29 @@ class GandiProvider extends AbstractProvider
]);
}
$res = $this->client->request('POST', '/domain/domains', $httpOptions->toArray());
$res = $this->client->request('POST', '/v5/domain/domains', $httpOptions->toArray());
if ((!$dryRun && Response::HTTP_ACCEPTED !== $res->getStatusCode())
|| ($dryRun && Response::HTTP_OK !== $res->getStatusCode())) {
throw new HttpException($res->toArray()['message']);
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)
->setBaseUri($this->kernel->isDebug() ? self::SANDBOX_BASE_URL : self::BASE_URL)
->toArray()
);
if (Response::HTTP_OK !== $response->getStatusCode()) {
throw new BadRequestHttpException('The status of these credentials is not valid');
throw new InvalidLoginException();
}
}
@@ -127,7 +131,7 @@ class GandiProvider extends AbstractProvider
$response = $this->client->request('GET', '/v5/domain/tlds', (new HttpOptions())
->setAuthBearer($this->authData->token)
->setHeader('Accept', 'application/json')
->setBaseUri(self::BASE_URL)
->setBaseUri($this->kernel->isDebug() ? self::SANDBOX_BASE_URL : self::BASE_URL)
->toArray())->toArray();
return array_map(fn ($tld) => $tld['name'], $response);

View File

@@ -1,17 +1,17 @@
<?php
namespace App\Service\Connector;
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\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -57,20 +57,17 @@ class NameComProvider extends AbstractProvider
$this->client->request(
'POST',
'/v4/domains',
'/core/v1/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,
],
'domainName' => $domain->getLdhName(),
'locked' => false,
'autorenewEnabled' => false,
'purchaseType' => 'registration',
'years' => 1,
// 'tldRequirements' => []
],
])
@@ -98,25 +95,22 @@ class NameComProvider extends AbstractProvider
/**
* @throws TransportExceptionInterface
* @throws InvalidLoginException
*/
protected function assertAuthentication(): void
{
try {
$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()
);
} catch (\Exception) {
throw new BadRequestHttpException('Invalid Login');
}
$response = $this->client->request(
'GET',
'/core/v1/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 new BadRequestHttpException('The status of these credentials is not valid');
throw InvalidLoginException::fromIdentifier($this->authData->username);
}
}
}

View File

@@ -1,15 +1,17 @@
<?php
namespace App\Service\Connector;
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\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
@@ -27,8 +29,8 @@ class NamecheapProvider extends AbstractProvider
/** @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';
private const BASE_URL = 'https://api.namecheap.com/xml.response';
private const SANDBOX_BASE_URL = 'https://api.sandbox.namecheap.com/xml.response';
public function __construct(
CacheItemPoolInterface $cacheItemPool,
@@ -36,6 +38,7 @@ class NamecheapProvider extends AbstractProvider
private readonly string $outgoingIp,
DenormalizerInterface&NormalizerInterface $serializer,
ValidatorInterface $validator,
private readonly KernelInterface $kernel,
) {
parent::__construct($cacheItemPool, $serializer, $validator);
}
@@ -49,7 +52,7 @@ class NamecheapProvider extends AbstractProvider
$addresses = $this->call('namecheap.users.address.getList', [], $dryRun)->AddressGetListResult->List;
if (count($addresses) < 1) {
throw new BadRequestHttpException('Namecheap account requires at least one address to purchase a domain');
throw new NamecheapRequiresAddressException();
}
$addressId = (string) $addresses->attributes()['AddressId'];
@@ -98,14 +101,14 @@ class NamecheapProvider extends AbstractProvider
'ClientIp' => $this->outgoingIp,
], $parameters);
$response = $this->client->request('POST', $dryRun ? self::SANDBOX_BASE_URL : self::BASE_URL, [
$response = $this->client->request('POST', ($this->kernel->isDebug() || $dryRun) ? self::SANDBOX_BASE_URL : self::BASE_URL, [
'query' => $actualParams,
]);
$data = new \SimpleXMLElement($response->getContent());
if ($data->Errors->Error) {
throw new BadRequestHttpException($data->Errors->Error);
throw new ProviderGenericErrorException($data->Errors->Error);
}
return $data->CommandResponse;
@@ -116,13 +119,14 @@ class NamecheapProvider extends AbstractProvider
* @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 BadRequestHttpException('Namecheap account requires at least one address to purchase a domain');
throw new NamecheapRequiresAddressException();
}
}

View File

@@ -1,10 +1,11 @@
<?php
namespace App\Service\Connector;
namespace App\Service\Provider;
use App\Dto\Connector\DefaultProviderDto;
use App\Dto\Connector\OpenProviderProviderDto;
use App\Entity\Domain;
use App\Service\Provider\AbstractProvider;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
@@ -27,7 +28,7 @@ class OpenProviderProvider extends AbstractProvider
/** @var OpenProviderProviderDto */
protected DefaultProviderDto $authData;
private const BASE_URL = 'https://api.openprovider.eu/v1beta';
private const string BASE_URL = 'https://api.openprovider.eu/v1beta';
public function __construct(
CacheItemPoolInterface $cacheItemPool,

View File

@@ -1,10 +1,15 @@
<?php
namespace App\Service\Connector;
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;
@@ -12,7 +17,6 @@ use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
@@ -104,7 +108,7 @@ class OvhProvider extends AbstractProvider
);
if (empty($offer)) {
$conn->delete("/order/cart/{$cartId}");
throw new \InvalidArgumentException('Cannot buy this domain name');
throw new DomainOrderFailedExeption();
}
$item = $conn->post("/order/cart/{$cartId}/domain", [
@@ -153,15 +157,15 @@ class OvhProvider extends AbstractProvider
try {
$res = $conn->get('/auth/currentCredential');
if (null !== $res['expiration'] && new \DateTimeImmutable($res['expiration']) < new \DateTimeImmutable()) {
throw new BadRequestHttpException('These credentials have expired');
throw ExpiredLoginException::fromIdentifier($this->authData->appKey);
}
$status = $res['status'];
if ('validated' !== $status) {
throw new BadRequestHttpException("The status of these credentials is not valid ($status)");
throw InvalidLoginStatusException::fromStatus($status);
}
} catch (ClientException $exception) {
throw new BadRequestHttpException($exception->getMessage());
throw new ProviderGenericErrorException($exception->getMessage());
}
foreach (self::REQUIRED_ROUTES as $requiredRoute) {
@@ -177,7 +181,7 @@ class OvhProvider extends AbstractProvider
}
if (!$ok) {
throw new BadRequestHttpException('This Connector does not have enough permissions on the Provider API. Please recreate this Connector.');
throw PermissionErrorException::fromIdentifier($this->authData->appKey);
}
}
}

View File

@@ -5,8 +5,6 @@ namespace App\Service;
use App\Config\DnsKey\Algorithm;
use App\Config\DnsKey\DigestType;
use App\Config\EventAction;
use App\Config\RegistrarStatus;
use App\Config\TldType;
use App\Entity\DnsKey;
use App\Entity\Domain;
use App\Entity\DomainEntity;
@@ -18,28 +16,30 @@ use App\Entity\Nameserver;
use App\Entity\NameserverEntity;
use App\Entity\RdapServer;
use App\Entity\Tld;
use App\Exception\DomainNotFoundException;
use App\Exception\MalformedDomainException;
use App\Exception\TldNotSupportedException;
use App\Exception\UnknownRdapServerException;
use App\Repository\DomainEntityRepository;
use App\Repository\DomainEventRepository;
use App\Repository\DomainRepository;
use App\Repository\DomainStatusRepository;
use App\Repository\EntityEventRepository;
use App\Repository\EntityRepository;
use App\Repository\IcannAccreditationRepository;
use App\Repository\NameserverEntityRepository;
use App\Repository\NameserverRepository;
use App\Repository\RdapServerRepository;
use App\Repository\TldRepository;
use Doctrine\DBAL\LockMode;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\OptimisticLockException;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
use Symfony\Component\HttpFoundation\Response;
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;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
@@ -48,39 +48,6 @@ use Symfony\Contracts\HttpClient\ResponseInterface;
class RDAPService
{
/* @see https://www.iana.org/domains/root/db */
public const ISO_TLD_EXCEPTION = ['ac', 'eu', 'uk', 'su', 'tp'];
public const INFRA_TLD = ['arpa'];
public const SPONSORED_TLD = [
'aero',
'asia',
'cat',
'coop',
'edu',
'gov',
'int',
'jobs',
'mil',
'museum',
'post',
'tel',
'travel',
'xxx',
];
public const TEST_TLD = [
'xn--kgbechtv',
'xn--hgbk6aj7f53bba',
'xn--0zwm56d',
'xn--g6w251d',
'xn--80akhbyknj4f',
'xn--11b5bs3a9aj6g',
'xn--jxalpdlp',
'xn--9t4b11yi5a',
'xn--deba0ad',
'xn--zckzah',
'xn--hlcj6aya9esc7a',
];
public const ENTITY_HANDLE_BLACKLIST = [
'REDACTED_FOR_PRIVACY',
'ANO00-FRNIC',
@@ -96,29 +63,39 @@ class RDAPService
'Private',
];
public function __construct(private HttpClientInterface $client,
private EntityRepository $entityRepository,
private DomainRepository $domainRepository,
private DomainEventRepository $domainEventRepository,
private NameserverRepository $nameserverRepository,
private NameserverEntityRepository $nameserverEntityRepository,
private EntityEventRepository $entityEventRepository,
private DomainEntityRepository $domainEntityRepository,
private RdapServerRepository $rdapServerRepository,
private TldRepository $tldRepository,
private EntityManagerInterface $em,
private LoggerInterface $logger,
private StatService $statService,
private InfluxdbService $influxService,
public function __construct(
private readonly HttpClientInterface $client,
private readonly EntityRepository $entityRepository,
private readonly DomainRepository $domainRepository,
private readonly DomainEventRepository $domainEventRepository,
private readonly NameserverRepository $nameserverRepository,
private readonly NameserverEntityRepository $nameserverEntityRepository,
private readonly EntityEventRepository $entityEventRepository,
private readonly DomainEntityRepository $domainEntityRepository,
private readonly RdapServerRepository $rdapServerRepository,
private readonly TldRepository $tldRepository,
private readonly IcannAccreditationRepository $icannAccreditationRepository,
private readonly EntityManagerInterface $em,
private readonly LoggerInterface $logger,
private readonly StatService $statService,
private readonly InfluxdbService $influxService,
#[Autowire(param: 'influxdb_enabled')]
private bool $influxdbEnabled,
private readonly bool $influxdbEnabled,
private readonly DomainStatusRepository $domainStatusRepository,
) {
}
/**
* @throws HttpExceptionInterface
* @throws TransportExceptionInterface
* @throws RedirectionExceptionInterface
* @throws DomainNotFoundException
* @throws DecodingExceptionInterface
* @throws TldNotSupportedException
* @throws ClientExceptionInterface
* @throws OptimisticLockException
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws MalformedDomainException
* @throws UnknownRdapServerException
*/
public function registerDomains(array $domains): void
{
@@ -128,11 +105,16 @@ class RDAPService
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws DomainNotFoundException
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws TldNotSupportedException
* @throws ClientExceptionInterface
* @throws OptimisticLockException
* @throws TransportExceptionInterface
* @throws MalformedDomainException
* @throws ServerExceptionInterface
* @throws UnknownRdapServerException
* @throws \Exception
*/
public function registerDomain(string $fqdn): Domain
@@ -140,8 +122,8 @@ class RDAPService
$idnDomain = RDAPService::convertToIdn($fqdn);
$tld = $this->getTld($idnDomain);
$this->logger->info('An update request for domain name {idnDomain} is requested.', [
'idnDomain' => $idnDomain,
$this->logger->debug('Update request for a domain name is requested', [
'ldhName' => $idnDomain,
]);
$rdapServer = $this->fetchRdapServer($tld);
@@ -160,7 +142,7 @@ class RDAPService
$this->updateDomainStatus($domain, $rdapData);
if (in_array('free', $domain->getStatus())) {
throw new NotFoundHttpException("The domain name $idnDomain is not present in the WHOIS database.");
throw DomainNotFoundException::fromDomain($idnDomain);
}
$domain
@@ -182,46 +164,69 @@ class RDAPService
return $domain;
}
/**
* @throws TldNotSupportedException
* @throws MalformedDomainException
*/
public function getTld(string $domain): Tld
{
if (!str_contains($domain, '.')) {
$tldEntity = $this->tldRepository->findOneBy(['tld' => '.']);
if (!str_contains($domain, OfficialDataService::DOMAIN_DOT)) {
$tldEntity = $this->tldRepository->findOneBy(['tld' => OfficialDataService::DOMAIN_DOT]);
if (null == $tldEntity) {
throw new NotFoundHttpException("The requested TLD $domain is not yet supported, please try again with another one");
throw TldNotSupportedException::fromTld(OfficialDataService::DOMAIN_DOT);
}
return $tldEntity;
}
$lastDotPosition = strrpos($domain, '.');
$lastDotPosition = strrpos($domain, OfficialDataService::DOMAIN_DOT);
if (false === $lastDotPosition) {
throw new BadRequestException('Domain must contain at least one dot');
throw MalformedDomainException::fromDomain($domain);
}
$tld = self::convertToIdn(substr($domain, $lastDotPosition + 1));
$tldEntity = $this->tldRepository->findOneBy(['tld' => $tld]);
$tldEntity = $this->tldRepository->findOneBy(['tld' => $tld, 'deletedAt' => null]);
if (null === $tldEntity) {
throw new NotFoundHttpException("The requested TLD $tld is not yet supported, please try again with another one");
$this->logger->debug('Domain name cannot be updated because the TLD is not supported', [
'ldhName' => $domain,
]);
throw TldNotSupportedException::fromTld($tld);
}
return $tldEntity;
}
/**
* @throws MalformedDomainException
*/
public static function convertToIdn(string $fqdn): string
{
return strtolower(idn_to_ascii($fqdn));
$ascii = strtolower(idn_to_ascii($fqdn));
if (OfficialDataService::DOMAIN_DOT !== $fqdn && !preg_match('/^(xn--)?[a-z0-9-]+(\.[a-z0-9-]+)*$/', $ascii)) {
throw MalformedDomainException::fromDomain($fqdn);
}
return $ascii;
}
/**
* @throws UnknownRdapServerException
*/
private function fetchRdapServer(Tld $tld): RdapServer
{
$tldString = $tld->getTld();
$rdapServer = $this->rdapServerRepository->findOneBy(['tld' => $tldString], ['updatedAt' => 'DESC']);
if (null === $rdapServer) {
throw new NotFoundHttpException("TLD $tldString : Unable to determine which RDAP server to contact");
$this->logger->debug('Unable to determine which RDAP server to contact', [
'tld' => $tldString,
]);
throw UnknownRdapServerException::fromTld($tldString);
}
return $rdapServer;
@@ -238,15 +243,14 @@ class RDAPService
private function fetchRdapResponse(RdapServer $rdapServer, string $idnDomain, ?Domain $domain): array
{
$rdapServerUrl = $rdapServer->getUrl();
$this->logger->notice('An RDAP query to update the domain name {idnDomain} will be made to {server}.', [
'idnDomain' => $idnDomain,
$this->logger->info('An RDAP query to update this domain name will be made', [
'ldhName' => $idnDomain,
'server' => $rdapServerUrl,
]);
try {
$this->statService->incrementStat('stats.rdap_queries.count');
$req = $this->client->request('GET', $rdapServerUrl.'domain/'.$idnDomain);
$this->statService->incrementStat('stats.rdap_queries.count');
return $req->toArray();
} catch (\Exception $e) {
@@ -269,8 +273,8 @@ class RDAPService
|| ($e instanceof TransportExceptionInterface && null !== $response && !in_array('content-length', $response->getHeaders(false)) && 404 === $response->getStatusCode())
) {
if (null !== $domain) {
$this->logger->notice('The domain name {idnDomain} has been deleted from the WHOIS database.', [
'idnDomain' => $idnDomain,
$this->logger->info('Domain name has been deleted from the WHOIS database', [
'ldhName' => $idnDomain,
]);
$domain->updateTimestamps();
@@ -285,13 +289,16 @@ class RDAPService
}
$domain->setDeleted(true);
$this->em->persist($domain);
$this->em->flush();
}
throw new NotFoundHttpException("The domain name $idnDomain is not present in the WHOIS database.");
throw DomainNotFoundException::fromDomain($idnDomain);
}
$this->logger->error('Unable to perform an RDAP query for this domain name', [
'ldhName' => $idnDomain,
]);
return $e;
}
@@ -299,8 +306,8 @@ class RDAPService
{
$domain = new Domain();
$this->logger->info('The domain name {idnDomain} was not known to this Domain Watchdog instance.', [
'idnDomain' => $idnDomain,
$this->logger->debug('Domain name was not known to this instance', [
'ldhName' => $idnDomain,
]);
return $domain->setTld($tld)->setLdhName($idnDomain)->setDeleted(false);
@@ -308,8 +315,8 @@ class RDAPService
private function updateDomainStatus(Domain $domain, array $rdapData): void
{
if (isset($rdapData['status'])) {
$status = array_unique($rdapData['status']);
if (isset($rdapData['status']) && is_array($rdapData['status'])) {
$status = array_map(fn ($s) => strtolower($s), array_unique($rdapData['status']));
$addedStatus = array_diff($status, $domain->getStatus());
$deletedStatus = array_diff($domain->getStatus(), $status);
$domain->setStatus($status);
@@ -327,8 +334,8 @@ class RDAPService
}
}
} else {
$this->logger->warning('The domain name {idnDomain} has no WHOIS status.', [
'idnDomain' => $domain->getLdhName(),
$this->logger->warning('Domain name has no WHOIS status', [
'ldhName' => $domain->getLdhName(),
]);
}
}
@@ -338,21 +345,18 @@ class RDAPService
if (isset($rdapData['handle'])) {
$domain->setHandle($rdapData['handle']);
} else {
$this->logger->warning('The domain name {idnDomain} has no handle key.', [
'idnDomain' => $domain->getLdhName(),
$this->logger->warning('Domain name has no handle key', [
'ldhName' => $domain->getLdhName(),
]);
}
}
/**
* @throws \DateMalformedStringException
* @throws \Exception
*/
private function updateDomainEvents(Domain $domain, array $rdapData): void
{
foreach ($domain->getEvents()->getIterator() as $event) {
$event->setDeleted(true);
}
$this->domainEventRepository->setDomainEventAsDeleted($domain);
if (isset($rdapData['events']) && is_array($rdapData['events'])) {
foreach ($rdapData['events'] as $rdapEvent) {
@@ -368,10 +372,14 @@ class RDAPService
if (null === $event) {
$event = new DomainEvent();
} else {
// at this point Doctrine doesn't know that the events are
// deleted in the database, so refresh in order to make the diff work
$this->em->refresh($event);
}
$domain->addEvent($event
->setAction($rdapEvent['eventAction'])
->setAction(strtolower($rdapEvent['eventAction']))
->setDate(new \DateTimeImmutable($rdapEvent['eventDate']))
->setDeleted(false));
@@ -381,14 +389,11 @@ class RDAPService
}
/**
* @throws \DateMalformedStringException
* @throws \Exception
*/
private function updateDomainEntities(Domain $domain, array $rdapData): void
{
foreach ($domain->getDomainEntities()->getIterator() as $domainEntity) {
$domainEntity->setDeleted(true);
}
$this->domainEntityRepository->setDomainEntityAsDeleted($domain);
if (!isset($rdapData['entities']) || !is_array($rdapData['entities'])) {
return;
@@ -405,13 +410,15 @@ class RDAPService
if (null === $domainEntity) {
$domainEntity = new DomainEntity();
} else {
$this->em->refresh($domainEntity);
}
$domain->addDomainEntity($domainEntity
->setDomain($domain)
->setEntity($entity)
->setRoles($roles)
->setDeleted(false));
->setDeletedAt(null));
$this->em->persist($domainEntity);
$this->em->flush();
@@ -419,11 +426,11 @@ class RDAPService
}
/**
* @throws \DateMalformedStringException
* @throws \Exception
*/
private function updateDomainNameservers(Domain $domain, array $rdapData): void
{
if (array_key_exists('nameservers', $rdapData) && is_array($rdapData['nameservers'])) {
if (isset($rdapData['nameservers']) && is_array($rdapData['nameservers'])) {
$domain->getNameservers()->clear();
$this->em->persist($domain);
@@ -436,19 +443,20 @@ class RDAPService
}
}
} else {
$this->logger->warning('The domain name {idnDomain} has no nameservers.', [
'idnDomain' => $domain->getLdhName(),
$this->logger->warning('Domain name has no nameservers', [
'ldhName' => $domain->getLdhName(),
]);
}
}
private function fetchOrCreateNameserver(array $rdapNameserver, Domain $domain): Nameserver
{
$ldhName = strtolower(rtrim($rdapNameserver['ldhName'], '.'));
$nameserver = $this->nameserverRepository->findOneBy([
'ldhName' => strtolower($rdapNameserver['ldhName']),
'ldhName' => $ldhName,
]);
$existingDomainNS = $domain->getNameservers()->findFirst(fn (int $key, Nameserver $ns) => $ns->getLdhName() === $rdapNameserver['ldhName']);
$existingDomainNS = $domain->getNameservers()->findFirst(fn (int $key, Nameserver $ns) => $ns->getLdhName() === $ldhName);
if (null !== $existingDomainNS) {
return $existingDomainNS;
@@ -456,13 +464,13 @@ class RDAPService
$nameserver = new Nameserver();
}
$nameserver->setLdhName($rdapNameserver['ldhName']);
$nameserver->setLdhName($ldhName);
return $nameserver;
}
/**
* @throws \DateMalformedStringException
* @throws \Exception
*/
private function updateNameserverEntities(Nameserver $nameserver, array $rdapNameserver, Tld $tld): void
{
@@ -486,7 +494,7 @@ class RDAPService
$nameserver->addNameserverEntity($nameserverEntity
->setNameserver($nameserver)
->setEntity($entity)
->setStatus(array_unique($rdapNameserver['status']))
->setStatus(array_map(fn ($s) => strtolower($s), array_unique($rdapNameserver['status'])))
->setRoles($roles));
$this->em->persist($nameserverEntity);
@@ -514,40 +522,14 @@ class RDAPService
$roles = array_merge(...$roles);
}
return $roles;
return array_map(fn ($x) => strtolower($x), $roles);
}
/**
* @throws \DateMalformedStringException
* @throws \Exception
*/
private function registerEntity(array $rdapEntity, array $roles, string $domain, Tld $tld): Entity
{
$entity = null;
/**
* If the RDAP server transmits the entity's IANA number, it is used as a priority to identify the entity.
*
* @see https://datatracker.ietf.org/doc/html/rfc7483#section-4.8
*/
$isIANAid = false;
if (isset($rdapEntity['publicIds'])) {
foreach ($rdapEntity['publicIds'] as $publicId) {
if ('IANA Registrar ID' === $publicId['type'] && isset($publicId['identifier']) && '' !== $publicId['identifier']) {
$entity = $this->entityRepository->findOneBy([
'handle' => $publicId['identifier'],
'tld' => null,
]);
if (null !== $entity) {
$rdapEntity['handle'] = $publicId['identifier'];
$isIANAid = true;
break;
}
}
}
}
/*
* If there is no number to identify the entity, one is generated from the domain name and the roles associated with this entity
*/
@@ -555,31 +537,43 @@ class RDAPService
sort($roles);
$rdapEntity['handle'] = 'DW-FAKEHANDLE-'.$domain.'-'.implode(',', $roles);
$this->logger->warning('The entity {handle} has no handle key.', [
$this->logger->warning('Entity has no handle key', [
'handle' => $rdapEntity['handle'],
'ldhName' => $domain,
]);
}
if (null === $entity) {
$entity = $this->entityRepository->findOneBy([
'handle' => $rdapEntity['handle'],
'tld' => $tld,
]);
}
if ($isIANAid && null !== $entity) {
return $entity;
}
$entity = $this->entityRepository->findOneBy([
'handle' => $rdapEntity['handle'],
'tld' => $tld,
]);
if (null === $entity) {
$entity = (new Entity())->setTld($tld);
$this->logger->info('The entity {handle} was not known to this Domain Watchdog instance.', [
$this->logger->debug('Entity was not known to this instance', [
'handle' => $rdapEntity['handle'],
'ldhName' => $domain,
]);
}
$entity->setHandle($rdapEntity['handle']);
/**
* If the RDAP server transmits the entity's IANA number, it is used as a priority to identify the entity.
*
* @see https://datatracker.ietf.org/doc/html/rfc7483#section-4.8
*/
$icannAccreditation = null;
if (isset($rdapEntity['publicIds'])) {
foreach ($rdapEntity['publicIds'] as $publicId) {
if ('IANA Registrar ID' === $publicId['type'] && isset($publicId['identifier']) && '' !== $publicId['identifier']) {
$icannAccreditation = $this->icannAccreditationRepository->findOneBy([
'id' => (int) $publicId['identifier'],
]);
}
}
}
$entity->setHandle($rdapEntity['handle'])->setIcannAccreditation($icannAccreditation);
if (isset($rdapEntity['remarks']) && is_array($rdapEntity['remarks'])) {
$entity->setRemarks($rdapEntity['remarks']);
@@ -644,7 +638,7 @@ class RDAPService
$entity->addEvent(
(new EntityEvent())
->setEntity($entity)
->setAction($rdapEntityEvent['eventAction'])
->setAction(strtolower($rdapEntityEvent['eventAction']))
->setDate(new \DateTimeImmutable($rdapEntityEvent['eventDate']))
->setDeleted(false));
}
@@ -675,12 +669,18 @@ class RDAPService
try {
$blob = hex2bin($rdapDsData['digest']);
} catch (\Exception) {
$this->logger->warning('DNSSEC digest is not a valid hexadecimal value.');
$this->logger->warning('DNSSEC digest is not a valid hexadecimal value', [
'ldhName' => $domain,
'value' => $rdapDsData['digest'],
]);
continue;
}
if (false === $blob) {
$this->logger->warning('DNSSEC digest is not a valid hexadecimal value.');
$this->logger->warning('DNSSEC digest is not a valid hexadecimal value', [
'ldhName' => $domain,
'value' => $rdapDsData['digest'],
]);
continue;
}
$dsData->setDigest($blob);
@@ -700,7 +700,11 @@ class RDAPService
if (array_key_exists($dsData->getDigestType()->value, $digestLengthByte)
&& strlen($dsData->getDigest()) / 2 !== $digestLengthByte[$dsData->getDigestType()->value]) {
$this->logger->warning('DNSSEC digest does not have a valid length according to the digest type.');
$this->logger->warning('DNSSEC digest does not have a valid length according to the digest type', [
'ldhName' => $domain,
'value' => $dsData->getDigest(),
'type' => $dsData->getDigestType()->name,
]);
continue;
}
@@ -708,237 +712,152 @@ class RDAPService
$this->em->persist($dsData);
}
} else {
$this->logger->warning('The domain name {idnDomain} has no DS record.', [
'idnDomain' => $domain->getLdhName(),
$this->logger->warning('Domain name has no DS record', [
'ldhName' => $domain->getLdhName(),
]);
}
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
* @throws ORMException
*/
public function updateRDAPServersFromIANA(): void
private function calculateDaysFromStatus(Domain $domain, \DateTimeImmutable $now): ?int
{
$this->logger->info('Start of update the RDAP server list from IANA.');
/** @var ?DomainStatus $lastStatus */
$lastStatus = $this->domainStatusRepository->findLastDomainStatus($domain);
$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 && null === $this->tldRepository->findOneBy(['tld' => $tld])) {
$this->em->persist((new Tld())->setTld('.')->setType(TldType::root));
$this->em->flush();
}
$tldEntity = $this->tldRepository->findOneBy(['tld' => $tld]);
if (null === $tldEntity) {
$tldEntity = (new Tld())->setTld($tld)->setType(TldType::gTLD);
$this->em->persist($tldEntity);
}
foreach ($service[1] as $rdapServerUrl) {
$server = $this->rdapServerRepository->findOneBy(['tld' => $tldEntity->getTld(), 'url' => $rdapServerUrl]);
if (null === $server) {
$server = new RdapServer();
}
$server
->setTld($tldEntity)
->setUrl($rdapServerUrl)
->setUpdatedAt(new \DateTimeImmutable($dnsRoot['publication'] ?? 'now'));
$this->em->persist($server);
}
}
}
$this->em->flush();
}
/**
* @throws ORMException
*/
public function updateRDAPServersFromFile(string $fileName): void
{
if (!file_exists($fileName)) {
return;
if (null === $lastStatus) {
return null;
}
$this->logger->info('Start of update the RDAP server list from custom config file.');
$this->updateRDAPServers(Yaml::parseFile($fileName));
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
*/
public function updateTldListIANA(): void
{
$this->logger->info('Start of retrieval of the list of TLDs according to IANA.');
$tldList = array_map(
fn ($tld) => strtolower($tld),
explode(PHP_EOL,
$this->client->request(
'GET', 'https://data.iana.org/TLD/tlds-alpha-by-domain.txt'
)->getContent()
));
array_shift($tldList);
foreach ($tldList as $tld) {
if ('' === $tld) {
continue;
}
$tldEntity = $this->tldRepository->findOneBy(['tld' => $tld]);
if (null === $tldEntity) {
$tldEntity = new Tld();
$tldEntity->setTld($tld);
$this->logger->notice('New TLD detected according to IANA ({tld}).', [
'tld' => $tld,
]);
}
$type = $this->getTldType($tld);
if (null !== $type) {
$tldEntity->setType($type);
} elseif (null === $tldEntity->isContractTerminated()) { // ICANN managed, must be a ccTLD
$tldEntity->setType(TldType::ccTLD);
} else {
$tldEntity->setType(TldType::gTLD);
}
$this->em->persist($tldEntity);
if ($domain->isPendingDelete() && (
in_array('pending delete', $lastStatus->getAddStatus())
|| in_array('redemption period', $lastStatus->getDeleteStatus()))
) {
return self::daysBetween($now, $lastStatus->getCreatedAt()->add(new \DateInterval('P'. 5 .'D')));
}
$this->em->flush();
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
* @throws \Exception
*/
public function updateRegistrarListIANA(): void
{
$this->logger->info('Start of retrieval of the list of Registrar IDs according to IANA.');
$registrarList = $this->client->request(
'GET', 'https://www.iana.org/assignments/registrar-ids/registrar-ids.xml'
);
$data = new \SimpleXMLElement($registrarList->getContent());
foreach ($data->registry->record as $registrar) {
$entity = $this->entityRepository->findOneBy(['handle' => $registrar->value, 'tld' => null]);
if (null === $entity) {
$entity = new Entity();
}
$entity
->setHandle($registrar->value)
->setTld(null)
->setJCard(['vcard', [['version', [], 'text', '4.0'], ['fn', [], 'text', (string) $registrar->name]]])
->setRemarks(null)
->getIcannAccreditation()
->setRegistrarName($registrar->name)
->setStatus(RegistrarStatus::from($registrar->status))
->setRdapBaseUrl($registrar->rdapurl->count() ? ($registrar->rdapurl->server) : null)
->setUpdated(null !== $registrar->attributes()->updated ? new \DateTimeImmutable($registrar->attributes()->updated) : null)
->setDate(null !== $registrar->attributes()->date ? new \DateTimeImmutable($registrar->attributes()->date) : null);
$this->em->persist($entity);
}
$this->em->flush();
}
private function getTldType(string $tld): ?TldType
{
if (in_array(strtolower($tld), self::ISO_TLD_EXCEPTION)) {
return TldType::ccTLD;
}
if (in_array(strtolower($tld), self::INFRA_TLD)) {
return TldType::iTLD;
}
if (in_array(strtolower($tld), self::SPONSORED_TLD)) {
return TldType::sTLD;
}
if (in_array(strtolower($tld), self::TEST_TLD)) {
return TldType::tTLD;
if ($domain->isRedemptionPeriod()
&& in_array('redemption period', $lastStatus->getAddStatus())
) {
return self::daysBetween($now, $lastStatus->getCreatedAt()->add(new \DateInterval('P'.(30 + 5).'D')));
}
return null;
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
* @throws DecodingExceptionInterface
* @throws \Exception
*/
public function updateGTldListICANN(): void
private function getRelevantDates(Domain $domain): array
{
$this->logger->info('Start of retrieval of the list of gTLDs according to ICANN.');
/** @var ?DomainEvent $expirationEvent */
$expirationEvent = $this->domainEventRepository->findLastDomainEvent($domain, 'expiration');
/** @var ?DomainEvent $deletionEvent */
$deletionEvent = $this->domainEventRepository->findLastDomainEvent($domain, 'deletion');
$gTldList = $this->client->request(
'GET', 'https://www.icann.org/resources/registries/gtlds/v2/gtlds.json'
)->toArray()['gTLDs'];
return [$expirationEvent?->getDate(), $deletionEvent?->getDate()];
}
foreach ($gTldList as $gTld) {
if ('' === $gTld['gTLD']) {
continue;
}
/** @var Tld|null $gtTldEntity */
$gtTldEntity = $this->tldRepository->findOneBy(['tld' => $gTld['gTLD']]);
if (null === $gtTldEntity) {
$gtTldEntity = new Tld();
$gtTldEntity->setTld($gTld['gTLD'])->setType(TldType::gTLD);
$this->logger->notice('New gTLD detected according to ICANN ({tld}).', [
'tld' => $gTld['gTLD'],
]);
}
$gtTldEntity
->setContractTerminated($gTld['contractTerminated'])
->setRegistryOperator($gTld['registryOperator'])
->setSpecification13($gTld['specification13']);
// NOTICE: sTLDs are listed in ICANN's gTLD list
if (null !== $gTld['removalDate']) {
$gtTldEntity->setRemovalDate(new \DateTimeImmutable($gTld['removalDate']));
}
if (null !== $gTld['delegationDate']) {
$gtTldEntity->setDelegationDate(new \DateTimeImmutable($gTld['delegationDate']));
}
if (null !== $gTld['dateOfContractSignature']) {
$gtTldEntity->setDateOfContractSignature(new \DateTimeImmutable($gTld['dateOfContractSignature']));
}
$this->em->persist($gtTldEntity);
public function getExpiresInDays(Domain $domain): ?int
{
if ($domain->getDeleted()) {
return null;
}
$this->em->flush();
$now = new \DateTimeImmutable();
[$expiredAt, $deletedAt] = $this->getRelevantDates($domain);
if ($expiredAt) {
$guess = self::daysBetween($now, $expiredAt->add(new \DateInterval('P'.(45 + 30 + 5).'D')));
}
if ($deletedAt) {
// It has been observed that AFNIC, on the last day, adds a "deleted" event and removes the redemption period status.
if (0 === self::daysBetween($now, $deletedAt) && $domain->isPendingDelete()) {
return 0;
}
$guess = self::daysBetween($now, $deletedAt->add(new \DateInterval('P'. 30 .'D')));
}
return self::returnExpiresIn([
$guess ?? null,
$this->calculateDaysFromStatus($domain, $now),
]);
}
/**
* Returns true if one or more of these conditions are met:
* - It has been more than 7 days since the domain name was last updated
* - It has been more than 12 minutes and the domain name has statuses that suggest it is not stable
* - It has been more than 1 day and the domain name is blocked in DNS
*
* @throws \Exception
*/
public function isToBeUpdated(Domain $domain, bool $fromUser = true, bool $intensifyLastDay = false): bool
{
$updatedAtDiff = $domain->getUpdatedAt()->diff(new \DateTimeImmutable());
if ($updatedAtDiff->days >= 7) {
return true;
}
if ($domain->getDeleted()) {
return $fromUser;
}
$expiresIn = $this->getExpiresInDays($domain);
if ($intensifyLastDay && (0 === $expiresIn || 1 === $expiresIn)) {
return true;
}
$minutesDiff = $updatedAtDiff->h * 60 + $updatedAtDiff->i;
if (($minutesDiff >= 12 || $fromUser) && $domain->isToBeWatchClosely()) {
return true;
}
if (
count(array_intersect($domain->getStatus(), ['auto renew period', 'client hold', 'server hold'])) > 0
&& $updatedAtDiff->days >= 1
) {
return true;
}
return false;
}
/*
private function calculateDaysFromEvents(\DateTimeImmutable $now): ?int
{
$lastChangedEvent = $this->getEvents()->findFirst(fn (int $i, DomainEvent $e) => !$e->getDeleted() && EventAction::LastChanged->value === $e->getAction());
if (null === $lastChangedEvent) {
return null;
}
if ($this->isRedemptionPeriod()) {
return self::daysBetween($now, $lastChangedEvent->getDate()->add(new \DateInterval('P'.(30 + 5).'D')));
}
if ($this->isPendingDelete()) {
return self::daysBetween($now, $lastChangedEvent->getDate()->add(new \DateInterval('P'. 5 .'D')));
}
return null;
}
*/
private static function daysBetween(\DateTimeImmutable $start, \DateTimeImmutable $end): int
{
$interval = $start->setTime(0, 0)->diff($end->setTime(0, 0));
return $interval->invert ? -$interval->days : $interval->days;
}
private static function returnExpiresIn(array $guesses): ?int
{
$filteredGuesses = array_filter($guesses, function ($value) {
return null !== $value;
});
if (empty($filteredGuesses)) {
return null;
}
return max(min($filteredGuesses), 0);
}
}

View File

@@ -5,39 +5,94 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Domain;
use App\Entity\Watchlist;
use App\Exception\DomainNotFoundException;
use App\Exception\MalformedDomainException;
use App\Exception\TldNotSupportedException;
use App\Exception\UnknownRdapServerException;
use App\Message\SendDomainEventNotif;
use App\Repository\DomainRepository;
use App\Service\RDAPService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\OptimisticLockException;
use Psr\Log\LoggerInterface;
use Random\Randomizer;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
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;
readonly class AutoRegisterDomainProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.item_provider')]
private ProviderInterface $itemProvider,
private RDAPService $RDAPService,
private EntityManagerInterface $entityManager,
private KernelInterface $kernel,
private ParameterBagInterface $parameterBag,
private RateLimiterFactory $rdapRequestsLimiter, private Security $security,
private RateLimiterFactory $rdapRequestsLimiter,
private Security $security,
private LoggerInterface $logger,
private DomainRepository $domainRepository,
private MessageBusInterface $bus,
private RequestStack $requestStack,
private EntityManagerInterface $em,
) {
}
/**
* @throws RedirectionExceptionInterface
* @throws DomainNotFoundException
* @throws TldNotSupportedException
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
* @throws OptimisticLockException
* @throws TransportExceptionInterface
* @throws MalformedDomainException
* @throws ServerExceptionInterface
* @throws UnknownRdapServerException
* @throws ExceptionInterface
* @throws \Exception
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$domain = $this->itemProvider->provide($operation, $uriVariables, $context);
$fromWatchlist = array_key_exists('root_operation', $context) && Watchlist::class === $context['root_operation']?->getClass();
$userId = $this->security->getUser()->getUserIdentifier();
$idnDomain = RDAPService::convertToIdn($uriVariables['ldhName']);
$this->logger->info('User wants to update a domain name', [
'username' => $userId,
'ldhName' => $idnDomain,
]);
$request = $this->requestStack->getCurrentRequest();
/** @var ?Domain $domain */
$domain = $this->domainRepository->findOneBy(['ldhName' => $idnDomain]);
// If the domain name exists in the database, recently updated and not important, we return the stored Domain
if (null !== $domain
&& !$domain->getDeleted()
&& !$this->RDAPService->isToBeUpdated($domain, true, true)
&& ($request && !filter_var($request->get('forced', false), FILTER_VALIDATE_BOOLEAN))
) {
$this->logger->debug('It is not necessary to update the domain name', [
'ldhName' => $idnDomain,
'updatedAt' => $domain->getUpdatedAt()->format(\DateTimeInterface::ATOM),
]);
if (!is_null($domain)) {
return $domain;
}
if (false === $this->kernel->isDebug() && true === $this->parameterBag->get('limited_features')) {
$limiter = $this->rdapRequestsLimiter->create($this->security->getUser()->getUserIdentifier());
$limiter = $this->rdapRequestsLimiter->create($userId);
$limit = $limiter->consume();
if (!$limit->isAccepted()) {
@@ -45,19 +100,38 @@ readonly class AutoRegisterDomainProvider implements ProviderInterface
}
}
$ldhName = RDAPService::convertToIdn($uriVariables['ldhName']);
$updatedAt = null === $domain ? new \DateTimeImmutable('now') : $domain->getUpdatedAt();
try {
$domain = $this->RDAPService->registerDomain($ldhName);
} catch (NotFoundHttpException) {
$domain = $this->RDAPService->registerDomain($idnDomain);
} catch (DomainNotFoundException $exception) {
if (!$fromWatchlist) {
throw $exception;
}
$domain = $this->domainRepository->findOneBy(['ldhName' => $idnDomain]);
if (null !== $domain) {
return $domain;
}
$domain = (new Domain())
->setLdhName($ldhName)
->setTld($this->RDAPService->getTld($ldhName))
->setLdhName($idnDomain)
->setTld($this->RDAPService->getTld($idnDomain))
->setDelegationSigned(false)
->setDeleted(true);
$this->entityManager->persist($domain);
$this->entityManager->flush();
$this->em->persist($domain);
$this->em->flush();
return $domain;
}
$randomizer = new Randomizer();
$watchlists = $randomizer->shuffleArray($domain->getWatchlists()->toArray());
/** @var Watchlist $watchlist */
foreach ($watchlists as $watchlist) {
$this->bus->dispatch(new SendDomainEventNotif($watchlist->getToken(), $domain->getLdhName(), $updatedAt));
}
return $domain;

View File

@@ -7,8 +7,8 @@ use ApiPlatform\State\ProcessorInterface;
use App\Config\ConnectorProvider;
use App\Entity\Connector;
use App\Entity\User;
use App\Service\Connector\AbstractProvider;
use App\Service\Connector\EppClientProvider;
use App\Service\Provider\AbstractProvider;
use App\Service\Provider\EppClientProvider;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -47,7 +47,7 @@ readonly class ConnectorCreateProcessor implements ProcessorInterface
$provider = $data->getProvider();
$this->logger->info('User {username} wants to register a connector from provider {provider}.', [
$this->logger->info('User wants to register a connector', [
'username' => $user->getUserIdentifier(),
'provider' => $provider->value,
]);
@@ -87,7 +87,7 @@ readonly class ConnectorCreateProcessor implements ProcessorInterface
$data->setAuthData($providerClient->authenticate($authData));
}
$this->logger->info('User {username} authentication data with the {provider} provider has been validated.', [
$this->logger->info('User authentication data with this provider has been validated', [
'username' => $user->getUserIdentifier(),
'provider' => $provider->value,
]);

View File

@@ -6,7 +6,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Config\ConnectorProvider;
use App\Entity\Connector;
use App\Service\Connector\EppClientProvider;
use App\Service\Provider\EppClientProvider;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
@@ -30,7 +30,7 @@ readonly class ConnectorDeleteProcessor implements ProcessorInterface
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
foreach ($data->getWatchLists()->getIterator() as $watchlist) {
foreach ($data->getWatchlists()->getIterator() as $watchlist) {
$watchlist->setConnector(null);
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Domain;
use App\Service\RDAPService;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query\ResultSetMapping;
use Symfony\Component\HttpFoundation\RequestStack;
readonly class FindDomainCollectionFromEntityProvider implements ProviderInterface
{
public function __construct(
private RequestStack $requestStack,
private EntityManagerInterface $em,
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
$request = $this->requestStack->getCurrentRequest();
$rsm = (new ResultSetMapping())
->addScalarResult('domain_ids', 'domain_ids');
$handleBlacklist = join(',', array_map(fn (string $s) => "'$s'", RDAPService::ENTITY_HANDLE_BLACKLIST));
$sql = <<<SQL
SELECT
array_agg(DISTINCT de.domain_id) AS domain_ids
FROM (
SELECT
e.handle AS handle,
e.id,
e.tld_id,
jsonb_path_query_first(
e.j_card,
'$[1] ? (@[0] == "fn")[3]'
) #>> '{}' AS fn,
jsonb_path_query_first(
e.j_card,
'$[1] ? (@[0] == "org")[3]'
) #>> '{}' AS org
FROM entity e
) sub
JOIN domain_entity de ON de.entity_uid = sub.id
WHERE LOWER(org||fn) NOT LIKE '%redacted%'
AND LOWER(org||fn) NOT LIKE '%privacy%'
AND LOWER(org||fn) NOT LIKE '%registration private%'
AND LOWER(org||fn) NOT LIKE '%domain administrator%'
AND LOWER(org||fn) NOT LIKE '%registry super user account%'
AND LOWER(org||fn) NOT LIKE '%ano nymous%'
AND LOWER(org||fn) NOT LIKE '%by proxy%'
AND handle NOT IN ($handleBlacklist)
AND de.roles @> '["registrant"]'
AND sub.tld_id IS NOT NULL
AND (LOWER(org) = LOWER(:registrant) OR LOWER(fn) = LOWER(:registrant));
SQL;
$result = $this->em->createNativeQuery($sql, $rsm)
->setParameter('registrant', trim($request->get('registrant')))
->getOneOrNullResult();
if (!$result) {
return null;
}
$domainList = array_filter(explode(',', trim($result['domain_ids'], '{}')));
if (empty($domainList)) {
return [];
}
return $this->em->getRepository(Domain::class)
->createQueryBuilder('d')
->where('d.ldhName IN (:list)')
->setParameter('list', $domainList)
->getQuery()
->getResult();
}
}

View File

@@ -6,10 +6,10 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Domain;
use App\Entity\User;
use App\Entity\WatchList;
use App\Entity\Watchlist;
use App\Notifier\TestChatNotification;
use App\Service\ChatNotificationService;
use App\Service\Connector\AbstractProvider;
use App\Service\Provider\AbstractProvider;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -19,7 +19,7 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Serializer\Exception\ExceptionInterface;
readonly class WatchListUpdateProcessor implements ProcessorInterface
readonly class WatchlistUpdateProcessor implements ProcessorInterface
{
public function __construct(
private Security $security,
@@ -34,9 +34,9 @@ readonly class WatchListUpdateProcessor implements ProcessorInterface
}
/**
* @param WatchList $data
* @param Watchlist $data
*
* @return WatchList
* @return Watchlist
*
* @throws ExceptionInterface
* @throws \Exception
@@ -45,30 +45,32 @@ readonly class WatchListUpdateProcessor implements ProcessorInterface
{
/** @var User $user */
$user = $this->security->getUser();
$data->setUser($user);
$data->setUser($user)->setToken($uriVariables['token'] ?? $data->getToken());
if ($this->parameterBag->get('limited_features')) {
if ($data->getDomains()->count() > (int) $this->parameterBag->get('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', [
$this->logger->notice('User tried to update a Watchlist : the maximum number of domains has been reached for this Watchlist', [
'username' => $user->getUserIdentifier(),
'watchlist' => $data->getToken(),
]);
throw new AccessDeniedHttpException('You have exceeded the maximum number of domain names allowed in this Watchlist');
}
$userWatchLists = $user->getWatchLists();
$userWatchlists = $user->getWatchlists();
/** @var Domain[] $trackedDomains */
$trackedDomains = $userWatchLists
->filter(fn (WatchList $wl) => $wl->getToken() !== $data->getToken())
->reduce(fn (array $acc, WatchList $wl) => [...$acc, ...$wl->getDomains()->toArray()], []);
$trackedDomains = $userWatchlists
->filter(fn (Watchlist $wl) => $wl->getToken() !== $data->getToken())
->reduce(fn (array $acc, Watchlist $wl) => [...$acc, ...$wl->getDomains()->toArray()], []);
/** @var Domain $domain */
foreach ($data->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', [
$this->logger->notice('User tried to update a Watchlist : it is forbidden to register the same domain name twice with limited mode', [
'username' => $user->getUserIdentifier(),
'watchlist' => $data->getToken(),
'ldhName' => $ldhName,
]);
@@ -77,8 +79,9 @@ readonly class WatchListUpdateProcessor implements ProcessorInterface
}
if (null !== $data->getWebhookDsn() && count($data->getWebhookDsn()) > (int) $this->parameterBag->get('limit_max_watchlist_webhooks')) {
$this->logger->notice('User {username} tried to update a Watchlist. The maximum number of webhooks has been reached.', [
$this->logger->notice('User tried to update a Watchlist : the maximum number of webhooks has been reached', [
'username' => $user->getUserIdentifier(),
'watchlist' => $data->getToken(),
]);
throw new AccessDeniedHttpException('You have exceeded the maximum number of webhooks allowed in this Watchlist');
@@ -89,7 +92,7 @@ readonly class WatchListUpdateProcessor implements ProcessorInterface
if ($connector = $data->getConnector()) {
if (!$user->getConnectors()->contains($connector)) {
$this->logger->notice('The Connector ({connector}) does not belong to the user.', [
$this->logger->notice('Connector does not belong to the user', [
'username' => $user->getUserIdentifier(),
'connector' => $connector->getId(),
]);
@@ -114,9 +117,10 @@ readonly class WatchListUpdateProcessor implements ProcessorInterface
$supported = $connectorProvider->isSupported(...$data->getDomains()->toArray());
if (!$supported) {
$this->logger->notice('The Connector ({connector}) does not support all TLDs in this Watchlist', [
$this->logger->debug('Connector does not support all TLDs in this Watchlist', [
'username' => $user->getUserIdentifier(),
'connector' => $connector->getId(),
'provider' => $connector->getProvider()->value,
]);
throw new BadRequestHttpException('This connector does not support all TLDs in this Watchlist');

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Story;
use App\Factory\UserFactory;
use Zenstruck\Foundry\Story;
final class DefaultUsersStory extends Story
{
public function build(): void
{
UserFactory::createMany(3);
}
}