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 1025d6a..982d866 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/Version20251108171723.php b/migrations/Version20251108171723.php new file mode 100644 index 0000000..2403e6b --- /dev/null +++ b/migrations/Version20251108171723.php @@ -0,0 +1,35 @@ +addSql('ALTER TABLE entity ADD j_card_fn VARCHAR(255) GENERATED ALWAYS AS (UPPER(jsonb_path_query_first(j_card, \'$[1]?(@[0] == "fn")[3]\') #>> \'{}\')) STORED'); + $this->addSql('ALTER TABLE entity ADD j_card_org VARCHAR(255) GENERATED ALWAYS AS (UPPER(jsonb_path_query_first(j_card, \'$[1]?(@[0] == "org")[3]\') #>> \'{}\')) STORED'); + $this->addSql('CREATE INDEX entity_j_card_fn_idx ON entity (j_card_fn)'); + $this->addSql('CREATE INDEX entity_j_card_org_idx ON entity (j_card_org)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX entity_j_card_fn_idx'); + $this->addSql('DROP INDEX entity_j_card_org_idx'); + $this->addSql('ALTER TABLE entity DROP j_card_fn'); + $this->addSql('ALTER TABLE entity DROP j_card_org'); + } +} diff --git a/src/Entity/Entity.php b/src/Entity/Entity.php index 66975e8..69bd3ab 100644 --- a/src/Entity/Entity.php +++ b/src/Entity/Entity.php @@ -14,6 +14,8 @@ use Symfony\Component\Serializer\Attribute\SerializedName; #[ORM\UniqueConstraint( columns: ['tld_id', 'handle'] )] +#[ORM\Index(name: 'entity_j_card_fn_idx', columns: ['j_card_fn'])] +#[ORM\Index(name: 'entity_j_card_org_idx', columns: ['j_card_org'])] class Entity { #[ORM\Id] @@ -63,6 +65,24 @@ class Entity #[Groups(['entity:item', 'domain:item', 'watchlist:item'])] private array $jCard = []; + #[ORM\Column( + type: 'string', + insertable: false, + updatable: false, + columnDefinition: "VARCHAR(255) GENERATED ALWAYS AS (LOWER(jsonb_path_query_first(j_card, '$[1]?(@[0] == \"fn\")[3]') #>> '{}')) STORED", + generated: 'ALWAYS', + )] + private ?string $jCardFn; + + #[ORM\Column( + type: 'string', + insertable: false, + updatable: false, + columnDefinition: "VARCHAR(255) GENERATED ALWAYS AS (LOWER(jsonb_path_query_first(j_card, '$[1]?(@[0] == \"org\")[3]') #>> '{}')) STORED", + generated: 'ALWAYS', + )] + private ?string $jCardOrg; + #[ORM\Column(nullable: true)] #[Groups(['entity:item', 'domain:item', 'watchlist:item'])] private ?array $remarks = null; @@ -239,4 +259,28 @@ class Entity return $this; } + + public function getJCardFn(): ?string + { + return $this->jCardFn; + } + + public function getJCardOrg(): ?string + { + return $this->jCardOrg; + } + + public function setJCardFn(?string $jCardFn): Entity + { + $this->jCardFn = $jCardFn; + + return $this; + } + + public function setJCardOrg(?string $jCardOrg): Entity + { + $this->jCardOrg = $jCardOrg; + + return $this; + } } diff --git a/src/State/FindDomainCollectionFromEntityProvider.php b/src/State/FindDomainCollectionFromEntityProvider.php index 620d9f8..d8f878b 100644 --- a/src/State/FindDomainCollectionFromEntityProvider.php +++ b/src/State/FindDomainCollectionFromEntityProvider.php @@ -4,79 +4,63 @@ namespace App\State; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; -use App\Entity\Domain; +use App\Repository\DomainRepository; +use App\Repository\EntityRepository; use App\Service\RDAPService; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Query\ResultSetMapping; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpKernel\Exception\HttpException; readonly class FindDomainCollectionFromEntityProvider implements ProviderInterface { public function __construct( private RequestStack $requestStack, - private EntityManagerInterface $em, + private EntityRepository $entityRepository, + private DomainRepository $domainRepository, ) { } public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { $request = $this->requestStack->getCurrentRequest(); - $rsm = (new ResultSetMapping()) - ->addScalarResult('domain_ids', 'domain_ids'); + $registrant = trim((string) $request->get('registrant')); - $handleBlacklist = join(',', array_map(fn (string $s) => "'$s'", RDAPService::ENTITY_HANDLE_BLACKLIST)); + $forbidden = [ + 'redacted', + 'privacy', + 'registration private', + 'domain administrator', + 'registry super user account', + 'ano nymous', + 'by proxy', + ]; - $sql = <<> '{}' 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 for privacy%' - AND LOWER(org||fn) NOT LIKE '%data protected%' - 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 '%redacted%' - AND LOWER(org||fn) NOT IN ('na', 'n/a', '-', 'none', 'not applicable') - 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; + foreach ($forbidden as $word) { + if (str_contains(strtolower($registrant), $word)) { + throw new HttpException(403, 'Forbidden search term'); + } } - $domainList = array_filter(explode(',', trim($result['domain_ids'], '{}'))); + $entities = $this->entityRepository->createQueryBuilder('e') + ->where('e.tld IS NOT NULL') + ->andWhere('e.handle NOT IN (:blacklist)') + ->andWhere('UPPER(e.jCardOrg) = UPPER(:registrant) OR UPPER(e.jCardFn) = UPPER(:registrant)') + ->setParameter('registrant', $registrant) + ->setParameter('blacklist', RDAPService::ENTITY_HANDLE_BLACKLIST) + ->getQuery() + ->getResult(); - if (empty($domainList)) { + if (empty($entities)) { return []; } - return $this->em->getRepository(Domain::class) - ->createQueryBuilder('d') - ->where('d.ldhName IN (:list)') - ->setParameter('list', $domainList) - ->getQuery() - ->getResult(); + return $this->domainRepository->createQueryBuilder('d') + ->select('DISTINCT d') + ->join('d.domainEntities', 'de') + ->where('de.entity IN (:entityIds)') + ->andWhere('JSONB_CONTAINS(de.roles, :role) = true') + ->andWhere('de.deletedAt IS NULL') + ->setParameter('entityIds', array_map(fn ($e) => $e->getId(), $entities)) + ->setParameter('role', '"registrant"') + ->getQuery()->getResult(); } }