From 1c66821fb67a8df5371906247616e0ef08d35765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Gangloff?= Date: Fri, 24 Oct 2025 14:09:21 +0200 Subject: [PATCH] feat: also use registrar RDAP server --- composer.json | 1 + composer.lock | 76 +++++++++++++++++- config/packages/doctrine.yaml | 3 + migrations/Version20251023000555.php | 39 ++++++++++ src/Entity/Entity.php | 17 +++- src/Repository/DomainEntityRepository.php | 14 ++++ src/Service/RDAPService.php | 95 ++++++++++++++++------- 7 files changed, 213 insertions(+), 32 deletions(-) create mode 100644 migrations/Version20251023000555.php diff --git a/composer.json b/composer.json index e3f8dcd..3e60d7d 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ "protonlabs/vobject": "^4.31", "psr/http-client": "^1.0", "runtime/frankenphp-symfony": "^0.2.0", + "scienta/doctrine-json-functions": "^6.3", "symfony/asset": "7.3.*", "symfony/asset-mapper": "7.3.*", "symfony/cache": "7.3.*", diff --git a/composer.lock b/composer.lock index 8622cf8..e8358ea 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "49fd7fad6776160ff572f3fb9201a8a2", + "content-hash": "1db291e0d108c6bb06d447134f1250c6", "packages": [ { "name": "api-platform/core", @@ -4189,6 +4189,78 @@ }, "time": "2024-09-06T08:00:55+00:00" }, + { + "name": "scienta/doctrine-json-functions", + "version": "6.3.0", + "source": { + "type": "git", + "url": "https://github.com/ScientaNL/DoctrineJsonFunctions.git", + "reference": "554b2fd281e976a791501fc4753ffd4c5891ec62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ScientaNL/DoctrineJsonFunctions/zipball/554b2fd281e976a791501fc4753ffd4c5891ec62", + "reference": "554b2fd281e976a791501fc4753ffd4c5891ec62", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.2 || ^4", + "doctrine/lexer": "^2.0 || ^3.0", + "doctrine/orm": "^2.19 || ^3", + "ext-pdo": "*", + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^9.0 || ^10.0 || ^11.0 || ^12.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^1.12", + "phpstan/phpstan-doctrine": "^1.4", + "phpstan/phpstan-phpunit": "^1.4", + "phpunit/phpunit": "^10.1", + "psalm/plugin-phpunit": "^0.18", + "slevomat/coding-standard": "~8", + "symfony/cache": "^5.4 || ^6.4 || ^7", + "vimeo/psalm": "^5.2", + "webmozart/assert": "^1.11" + }, + "suggest": { + "dunglas/doctrine-json-odm": "To serialize / deserialize objects as JSON documents." + }, + "type": "library", + "autoload": { + "psr-4": { + "Scienta\\DoctrineJsonFunctions\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Doctrine Json Functions Contributors", + "homepage": "https://github.com/ScientaNL/DoctrineJsonFunctions/contributors" + } + ], + "description": "A set of extensions to Doctrine that add support for json query functions.", + "keywords": [ + "database", + "doctrine", + "dql", + "json", + "mariadb", + "mysql", + "orm", + "postgres", + "postgresql", + "sqlite" + ], + "support": { + "issues": "https://github.com/ScientaNL/DoctrineJsonFunctions/issues", + "source": "https://github.com/ScientaNL/DoctrineJsonFunctions/tree/6.3.0" + }, + "time": "2024-11-08T12:33:19+00:00" + }, { "name": "symfony/asset", "version": "v7.3.0", @@ -15319,7 +15391,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": ">=8.2", + "php": ">=8.4", "ext-ctype": "*", "ext-iconv": "*", "ext-simplexml": "*" diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 718692d..75f2fc7 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -24,6 +24,9 @@ doctrine: alias: App controller_resolver: auto_mapping: false + dql: + string_functions: + JSONB_CONTAINS: Scienta\DoctrineJsonFunctions\Query\AST\Functions\Postgresql\JsonbContains when@test: doctrine: diff --git a/migrations/Version20251023000555.php b/migrations/Version20251023000555.php new file mode 100644 index 0000000..ccc6d35 --- /dev/null +++ b/migrations/Version20251023000555.php @@ -0,0 +1,39 @@ +addSql('DROP INDEX uniq_e28446850f7084e918020d9'); + $this->addSql('ALTER TABLE entity ADD from_accredited_registrar_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE entity ADD CONSTRAINT FK_E2844687CB19E6A FOREIGN KEY (from_accredited_registrar_id) REFERENCES icann_accreditation (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_E2844687CB19E6A ON entity (from_accredited_registrar_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_E28446850F7084E918020D97CB19E6A ON entity (tld_id, handle, from_accredited_registrar_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE entity DROP CONSTRAINT FK_E2844687CB19E6A'); + $this->addSql('DROP INDEX IDX_E2844687CB19E6A'); + $this->addSql('DROP INDEX UNIQ_E28446850F7084E918020D97CB19E6A'); + $this->addSql('ALTER TABLE entity DROP from_accredited_registrar_id'); + $this->addSql('CREATE UNIQUE INDEX uniq_e28446850f7084e918020d9 ON entity (tld_id, handle)'); + } +} diff --git a/src/Entity/Entity.php b/src/Entity/Entity.php index 79be619..218a268 100644 --- a/src/Entity/Entity.php +++ b/src/Entity/Entity.php @@ -13,7 +13,7 @@ use Symfony\Component\Serializer\Attribute\SerializedName; #[ORM\Entity(repositoryClass: EntityRepository::class)] #[ORM\UniqueConstraint( - columns: ['tld_id', 'handle'] + columns: ['tld_id', 'handle', 'from_accredited_registrar_id'] )] #[ApiResource( operations: [ @@ -96,6 +96,9 @@ class Entity #[Groups(['entity:list', 'entity:item', 'domain:item'])] private ?IcannAccreditation $icannAccreditation = null; + #[ORM\ManyToOne] + private ?IcannAccreditation $fromAccreditedRegistrar = null; + public function __construct() { $this->domainEntities = new ArrayCollection(); @@ -264,4 +267,16 @@ class Entity return $this; } + + public function getFromAccreditedRegistrar(): ?IcannAccreditation + { + return $this->fromAccreditedRegistrar; + } + + public function setFromAccreditedRegistrar(?IcannAccreditation $fromAccreditedRegistrar): static + { + $this->fromAccreditedRegistrar = $fromAccreditedRegistrar; + + return $this; + } } diff --git a/src/Repository/DomainEntityRepository.php b/src/Repository/DomainEntityRepository.php index 5bf5206..ea3c48a 100644 --- a/src/Repository/DomainEntityRepository.php +++ b/src/Repository/DomainEntityRepository.php @@ -29,6 +29,20 @@ class DomainEntityRepository extends ServiceEntityRepository ->getQuery()->execute(); } + public function getDomainEntityFromDomainAndRoles(Domain $domain, array $roles) + { + return $this->createQueryBuilder('de') + ->select() + ->where('de.deletedAt IS NULL') + ->andWhere('de.domain = :domain') + ->andWhere('JSONB_CONTAINS(de.roles, :roles) = true') + ->setMaxResults(1) + ->getQuery() + ->setParameter('domain', $domain) + ->setParameter('roles', json_encode($roles)) + ->getOneOrNullResult(); + } + // /** // * @return DomainEntity[] Returns an array of DomainEntity objects // */ diff --git a/src/Service/RDAPService.php b/src/Service/RDAPService.php index 62a804a..a2d6d31 100644 --- a/src/Service/RDAPService.php +++ b/src/Service/RDAPService.php @@ -12,6 +12,7 @@ use App\Entity\DomainEvent; use App\Entity\DomainStatus; use App\Entity\Entity; use App\Entity\EntityEvent; +use App\Entity\IcannAccreditation; use App\Entity\Nameserver; use App\Entity\NameserverEntity; use App\Entity\RdapServer; @@ -49,6 +50,7 @@ use Symfony\Contracts\HttpClient\ResponseInterface; class RDAPService { public const ENTITY_HANDLE_BLACKLIST = [ + 'REDACTED', 'REDACTED_FOR_PRIVACY', 'ANO00-FRNIC', 'not applicable', @@ -117,7 +119,7 @@ class RDAPService * @throws UnknownRdapServerException * @throws \Exception */ - public function registerDomain(string $fqdn): Domain + public function registerDomain(string $fqdn, ?IcannAccreditation $icannAccreditation = null): Domain { $idnDomain = RDAPService::convertToIdn($fqdn); $tld = $this->getTld($idnDomain); @@ -126,10 +128,11 @@ class RDAPService 'ldhName' => $idnDomain, ]); - $rdapServer = $this->fetchRdapServer($tld); $domain = $this->domainRepository->findOneBy(['ldhName' => $idnDomain]); - $rdapData = $this->fetchRdapResponse($rdapServer, $idnDomain, $domain); + $rdapServer = $icannAccreditation ? (new RdapServer())->setUrl($icannAccreditation->getRdapBaseUrl()) : $this->fetchRdapServer($tld); + + $rdapData = $this->fetchRdapResponse($rdapServer, $idnDomain, $domain, null === $icannAccreditation); $this->em->beginTransaction(); if (null === $domain) { @@ -139,25 +142,49 @@ class RDAPService $this->em->lock($domain, LockMode::PESSIMISTIC_WRITE); - $this->updateDomainStatus($domain, $rdapData); + if (null === $icannAccreditation) { + $this->updateDomainStatus($domain, $rdapData); - if (in_array('free', $domain->getStatus())) { - throw DomainNotFoundException::fromDomain($idnDomain); + if (in_array('free', $domain->getStatus())) { + throw DomainNotFoundException::fromDomain($idnDomain); + } + + $domain + ->setRdapServer($rdapServer) + ->setDelegationSigned(isset($rdapData['secureDNS']['delegationSigned']) && $rdapData['secureDNS']['delegationSigned']); + + $this->updateDomainHandle($domain, $rdapData); + + $this->updateDomainEvents($domain, $rdapData); + + $this->updateDomainEntities($domain, $rdapData); + + $this->updateDomainNameservers($domain, $rdapData); + $this->updateDomainDsData($domain, $rdapData); + + $domain->setDeleted(false)->updateTimestamps(); + + /** @var ?DomainEntity $registrar */ + $registrar = $this->domainEntityRepository->getDomainEntityFromDomainAndRoles($domain, ['registrar']); + + if (null !== $registrar?->getEntity()?->getIcannAccreditation()?->getRdapBaseUrl()) { + try { + $this->registerDomain($idnDomain, $registrar->getEntity()->getIcannAccreditation()); + } catch (DomainNotFoundException) { + $this->logger->warning('Domain name exists for the registry but not for the registrar', [ + 'ldhName' => $domain->getLdhName(), + 'icannAccreditation' => $registrar->getEntity()->getIcannAccreditation()->getId(), + ]); + } catch (\Throwable) { + $this->logger->error('Domain name cannot be updated from the registrar RDAP server', [ + 'ldhName' => $domain->getLdhName(), + 'icannAccreditation' => $registrar->getEntity()->getIcannAccreditation()->getId(), + ]); + } + } + } else { + $this->updateDomainEntities($domain, $rdapData, $icannAccreditation); } - - $domain - ->setRdapServer($rdapServer) - ->setDelegationSigned(isset($rdapData['secureDNS']['delegationSigned']) && $rdapData['secureDNS']['delegationSigned']); - - $this->updateDomainHandle($domain, $rdapData); - - $this->updateDomainEvents($domain, $rdapData); - $this->updateDomainEntities($domain, $rdapData); - $this->updateDomainNameservers($domain, $rdapData); - $this->updateDomainDsData($domain, $rdapData); - - $domain->setDeleted(false)->updateTimestamps(); - $this->em->flush(); $this->em->commit(); @@ -240,7 +267,7 @@ class RDAPService * @throws ClientExceptionInterface * @throws \Exception */ - private function fetchRdapResponse(RdapServer $rdapServer, string $idnDomain, ?Domain $domain): array + private function fetchRdapResponse(RdapServer $rdapServer, string $idnDomain, ?Domain $domain, bool $handleRdapException = true): array { $rdapServerUrl = $rdapServer->getUrl(); $this->logger->info('An RDAP query to update this domain name will be made', [ @@ -254,7 +281,10 @@ class RDAPService return $req->toArray(); } catch (\Exception $e) { - throw $this->handleRdapException($e, $idnDomain, $domain, $req ?? null); + if ($handleRdapException) { + throw $this->handleRdapException($e, $idnDomain, $domain, $req ?? null); + } + throw DomainNotFoundException::fromDomain($idnDomain); } finally { if ($this->influxdbEnabled && isset($req)) { $this->influxService->addRdapQueryPoint($rdapServer, $idnDomain, $req->getInfo()); @@ -392,9 +422,11 @@ class RDAPService /** * @throws \Exception */ - private function updateDomainEntities(Domain $domain, array $rdapData): void + private function updateDomainEntities(Domain $domain, array $rdapData, ?IcannAccreditation $icannAccreditation = null): void { - $this->domainEntityRepository->setDomainEntityAsDeleted($domain); + if (!$icannAccreditation) { + $this->domainEntityRepository->setDomainEntityAsDeleted($domain); + } if (!isset($rdapData['entities']) || !is_array($rdapData['entities'])) { return; @@ -402,7 +434,11 @@ class RDAPService foreach ($rdapData['entities'] as $rdapEntity) { $roles = $this->extractEntityRoles($rdapData['entities'], $rdapEntity); - $entity = $this->registerEntity($rdapEntity, $roles, $domain->getLdhName(), $domain->getTld()); + if ($domain->getDomainEntities()->findFirst(fn (int $i, DomainEntity $de) => count(array_intersect($de->getRoles(), $roles)) > 0)) { + continue; + } + + $entity = $this->registerEntity($rdapEntity, $roles, $domain->getLdhName(), $domain->getTld(), $icannAccreditation); $domainEntity = $this->domainEntityRepository->findOneBy([ 'domain' => $domain, @@ -513,8 +549,8 @@ class RDAPService ? $targetEntity['handle'] === $e['handle'] : ( isset($targetEntity['vcardArray']) && isset($e['vcardArray']) - ? $targetEntity['vcardArray'] === $e['vcardArray'] - : $targetEntity === $e + ? $targetEntity['vcardArray'] === $e['vcardArray'] + : $targetEntity === $e ) ) ); @@ -529,7 +565,7 @@ class RDAPService /** * @throws \Exception */ - private function registerEntity(array $rdapEntity, array $roles, string $domain, Tld $tld): Entity + private function registerEntity(array $rdapEntity, array $roles, string $domain, Tld $tld, ?IcannAccreditation $fromAccreditedRegistrar = null): Entity { /* * If there is no number to identify the entity, one is generated from the domain name and the roles associated with this entity @@ -547,6 +583,7 @@ class RDAPService $entity = $this->entityRepository->findOneBy([ 'handle' => $rdapEntity['handle'], 'tld' => $tld, + 'fromAccreditedRegistrar' => $fromAccreditedRegistrar, ]); if (null === $entity) { @@ -574,7 +611,7 @@ class RDAPService } } - $entity->setHandle($rdapEntity['handle'])->setIcannAccreditation($icannAccreditation); + $entity->setHandle($rdapEntity['handle'])->setIcannAccreditation($icannAccreditation)->setFromAccreditedRegistrar($fromAccreditedRegistrar); if (isset($rdapEntity['remarks']) && is_array($rdapEntity['remarks'])) { $entity->setRemarks($rdapEntity['remarks']);