diff --git a/composer.json b/composer.json index 40c68f5..4eaa635 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "eluceo/ical": "^2.14", "influxdata/influxdb-client-php": "^3.6", "knpuniversity/oauth2-client-bundle": "^2.18", + "laminas/laminas-feed": "^2.23", "lexik/jwt-authentication-bundle": "^3.1", "metaregistrar/php-epp-client": "^1.0", "nelmio/cors-bundle": "^2.5", diff --git a/composer.lock b/composer.lock index 926249e..fadc162 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": "a4fba182d42fd845c37ea2a74b65566e", + "content-hash": "996e9f565d9f45226fe13354e8fde102", "packages": [ { "name": "api-platform/core", @@ -2153,6 +2153,206 @@ }, "time": "2024-10-02T14:26:09+00:00" }, + { + "name": "laminas/laminas-escaper", + "version": "2.16.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-escaper.git", + "reference": "9cf1f5317ca65b4fd5c6a3c2855e24a187b288c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/9cf1f5317ca65b4fd5c6a3c2855e24a187b288c8", + "reference": "9cf1f5317ca65b4fd5c6a3c2855e24a187b288c8", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-mbstring": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "conflict": { + "zendframework/zend-escaper": "*" + }, + "require-dev": { + "infection/infection": "^0.29.8", + "laminas/laminas-coding-standard": "~3.0.1", + "phpunit/phpunit": "^10.5.45", + "psalm/plugin-phpunit": "^0.19.2", + "vimeo/psalm": "^6.6.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Escaper\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs", + "homepage": "https://laminas.dev", + "keywords": [ + "escaper", + "laminas" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-escaper/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-escaper/issues", + "rss": "https://github.com/laminas/laminas-escaper/releases.atom", + "source": "https://github.com/laminas/laminas-escaper" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-02-17T12:40:19+00:00" + }, + { + "name": "laminas/laminas-feed", + "version": "2.23.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-feed.git", + "reference": "23807e692b3174750b426143bd93572b71b6739a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-feed/zipball/23807e692b3174750b426143bd93572b71b6739a", + "reference": "23807e692b3174750b426143bd93572b71b6739a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "laminas/laminas-escaper": "^2.9", + "laminas/laminas-stdlib": "^3.6", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "conflict": { + "laminas/laminas-servicemanager": "<3.3", + "zendframework/zend-feed": "*" + }, + "require-dev": { + "laminas/laminas-cache": "^2.13.2 || ^3.12", + "laminas/laminas-cache-storage-adapter-memory": "^1.1.0 || ^2.3", + "laminas/laminas-coding-standard": "~2.5.0", + "laminas/laminas-db": "^2.18", + "laminas/laminas-http": "^2.19", + "laminas/laminas-servicemanager": "^3.22.1", + "laminas/laminas-validator": "^2.46", + "phpunit/phpunit": "^10.5.5", + "psalm/plugin-phpunit": "^0.19.0", + "psr/http-message": "^2.0", + "vimeo/psalm": "^5.18.0" + }, + "suggest": { + "laminas/laminas-cache": "Laminas\\Cache component, for optionally caching feeds between requests", + "laminas/laminas-db": "Laminas\\Db component, for use with PubSubHubbub", + "laminas/laminas-http": "Laminas\\Http for PubSubHubbub, and optionally for use with Laminas\\Feed\\Reader", + "laminas/laminas-servicemanager": "Laminas\\ServiceManager component, for easily extending ExtensionManager implementations", + "laminas/laminas-validator": "Laminas\\Validator component, for validating email addresses used in Atom feeds and entries when using the Writer subcomponent", + "psr/http-message": "PSR-7 ^1.0.1, if you wish to use Laminas\\Feed\\Reader\\Http\\Psr7ResponseDecorator" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Feed\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "provides functionality for creating and consuming RSS and Atom feeds", + "homepage": "https://laminas.dev", + "keywords": [ + "atom", + "feed", + "laminas", + "rss" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-feed/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-feed/issues", + "rss": "https://github.com/laminas/laminas-feed/releases.atom", + "source": "https://github.com/laminas/laminas-feed" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2024-10-09T10:53:30+00:00" + }, + { + "name": "laminas/laminas-stdlib", + "version": "3.20.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-stdlib.git", + "reference": "8974a1213be42c3e2f70b2c27b17f910291ab2f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/8974a1213be42c3e2f70b2c27b17f910291ab2f4", + "reference": "8974a1213be42c3e2f70b2c27b17f910291ab2f4", + "shasum": "" + }, + "require": { + "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "conflict": { + "zendframework/zend-stdlib": "*" + }, + "require-dev": { + "laminas/laminas-coding-standard": "^3.0", + "phpbench/phpbench": "^1.3.1", + "phpunit/phpunit": "^10.5.38", + "psalm/plugin-phpunit": "^0.19.0", + "vimeo/psalm": "^5.26.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Stdlib\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "SPL extensions, array utilities, error handlers, and more", + "homepage": "https://laminas.dev", + "keywords": [ + "laminas", + "stdlib" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-stdlib/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-stdlib/issues", + "rss": "https://github.com/laminas/laminas-stdlib/releases.atom", + "source": "https://github.com/laminas/laminas-stdlib" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2024-10-29T13:46:07+00:00" + }, { "name": "lcobucci/clock", "version": "3.3.1", diff --git a/config/packages/security.yaml b/config/packages/security.yaml index c725334..07fe14e 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -61,6 +61,7 @@ security: - { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/api/register$, roles: PUBLIC_ACCESS } - { path: "^/api/watchlists/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/calendar$", roles: PUBLIC_ACCESS } + - { path: "^/api/watchlists/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/rss", roles: PUBLIC_ACCESS } - { path: "^/api/config$", roles: PUBLIC_ACCESS } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } diff --git a/src/Controller/WatchListController.php b/src/Controller/WatchListController.php index 025f777..181daf1 100644 --- a/src/Controller/WatchListController.php +++ b/src/Controller/WatchListController.php @@ -4,8 +4,8 @@ namespace App\Controller; use App\Entity\Connector; use App\Entity\Domain; -use App\Entity\DomainEntity; use App\Entity\DomainEvent; +use App\Entity\DomainStatus; use App\Entity\User; use App\Entity\WatchList; use App\Notifier\TestChatNotification; @@ -19,23 +19,19 @@ use Doctrine\DBAL\LockMode; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Exception\ORMException; use Doctrine\ORM\OptimisticLockException; -use Eluceo\iCal\Domain\Entity\Attendee; use Eluceo\iCal\Domain\Entity\Calendar; -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 Eluceo\iCal\Presentation\Component\Property; use Eluceo\iCal\Presentation\Component\Property\Value\TextValue; use Eluceo\iCal\Presentation\Factory\CalendarFactory; +use Exception; +use JsonException; +use Laminas\Feed\Writer\Deleted; +use Laminas\Feed\Writer\Entry; +use Laminas\Feed\Writer\Feed; use Psr\Log\LoggerInterface; use Sabre\VObject\EofException; use Sabre\VObject\InvalidDataException; use Sabre\VObject\ParseException; -use Sabre\VObject\Reader; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -61,17 +57,18 @@ class WatchListController extends AbstractController { public function __construct( private readonly SerializerInterface&DecoderInterface&DenormalizerInterface $serializer, - private readonly EntityManagerInterface $em, - private readonly WatchListRepository $watchListRepository, - private readonly LoggerInterface $logger, - private readonly ChatNotificationService $chatNotificationService, - private readonly DomainRepository $domainRepository, - private readonly RDAPService $RDAPService, - private readonly RateLimiterFactory $rdapRequestsLimiter, - private readonly KernelInterface $kernel, + private readonly EntityManagerInterface $em, + private readonly WatchListRepository $watchListRepository, + private readonly LoggerInterface $logger, + private readonly ChatNotificationService $chatNotificationService, + private readonly DomainRepository $domainRepository, + private readonly RDAPService $RDAPService, + private readonly RateLimiterFactory $rdapRequestsLimiter, + private readonly KernelInterface $kernel, #[Autowire(service: 'service_container')] - private readonly ContainerInterface $locator, - ) { + private readonly ContainerInterface $locator, + ) + { } /** @@ -81,8 +78,8 @@ class WatchListController extends AbstractController * @throws ExceptionInterface * @throws DecodingExceptionInterface * @throws ClientExceptionInterface - * @throws \JsonException - * @throws \Exception + * @throws JsonException + * @throws Exception */ #[Route( path: '/api/watchlists', @@ -106,7 +103,7 @@ class WatchListController extends AbstractController * This policy guarantees the equal probability of obtaining a domain name if it is requested by several users. */ if ($this->getParameter('limited_features')) { - if ($watchList->getDomains()->count() > (int) $this->getParameter('limit_max_watchlist_domains')) { + if ($watchList->getDomains()->count() > (int)$this->getParameter('limit_max_watchlist_domains')) { $this->logger->notice('User {username} tried to create a Watchlist. The maximum number of domains has been reached.', [ 'username' => $user->getUserIdentifier(), ]); @@ -114,7 +111,7 @@ class WatchListController extends AbstractController } $userWatchLists = $user->getWatchLists(); - if ($userWatchLists->count() >= (int) $this->getParameter('limit_max_watchlist')) { + if ($userWatchLists->count() >= (int)$this->getParameter('limit_max_watchlist')) { $this->logger->notice('User {username} tried to create a Watchlist. The maximum number of Watchlists has been reached', [ 'username' => $user->getUserIdentifier(), ]); @@ -122,7 +119,7 @@ class WatchListController extends AbstractController } /** @var Domain[] $trackedDomains */ - $trackedDomains = $userWatchLists->reduce(fn (array $acc, WatchList $watchList) => [...$acc, ...$watchList->getDomains()->toArray()], []); + $trackedDomains = $userWatchLists->reduce(fn(array $acc, WatchList $watchList) => [...$acc, ...$watchList->getDomains()->toArray()], []); /** @var Domain $domain */ foreach ($watchList->getDomains()->getIterator() as $domain) { @@ -138,7 +135,7 @@ class WatchListController extends AbstractController } } - if (null !== $watchList->getWebhookDsn() && count($watchList->getWebhookDsn()) > (int) $this->getParameter('limit_max_watchlist_webhooks')) { + if (null !== $watchList->getWebhookDsn() && count($watchList->getWebhookDsn()) > (int)$this->getParameter('limit_max_watchlist_webhooks')) { $this->logger->notice('User {username} tried to create a Watchlist. The maximum number of webhooks has been reached.', [ 'username' => $user->getUserIdentifier(), ]); @@ -178,7 +175,7 @@ class WatchListController extends AbstractController } /** - * @throws \Exception + * @throws Exception * @throws ExceptionInterface */ private function verifyConnector(WatchList $watchList, ?Connector $connector): void @@ -226,12 +223,12 @@ class WatchListController extends AbstractController * @throws RedirectionExceptionInterface * @throws DecodingExceptionInterface * @throws ClientExceptionInterface - * @throws \JsonException + * @throws JsonException * @throws OptimisticLockException * @throws TransportExceptionInterface * @throws ServerExceptionInterface * @throws ExceptionInterface - * @throws \Exception + * @throws Exception */ #[Route( path: '/api/watchlists/{token}', @@ -251,7 +248,7 @@ class WatchListController extends AbstractController $watchList->setUser($user); if ($this->getParameter('limited_features')) { - if ($watchList->getDomains()->count() > (int) $this->getParameter('limit_max_watchlist_domains')) { + if ($watchList->getDomains()->count() > (int)$this->getParameter('limit_max_watchlist_domains')) { $this->logger->notice('User {username} tried to update a Watchlist. The maximum number of domains has been reached for this Watchlist', [ 'username' => $user->getUserIdentifier(), ]); @@ -262,8 +259,8 @@ class WatchListController extends AbstractController /** @var Domain[] $trackedDomains */ $trackedDomains = $userWatchLists - ->filter(fn (WatchList $wl) => $wl->getToken() !== $watchList->getToken()) - ->reduce(fn (array $acc, WatchList $wl) => [...$acc, ...$wl->getDomains()->toArray()], []); + ->filter(fn(WatchList $wl) => $wl->getToken() !== $watchList->getToken()) + ->reduce(fn(array $acc, WatchList $wl) => [...$acc, ...$wl->getDomains()->toArray()], []); /** @var Domain $domain */ foreach ($watchList->getDomains()->getIterator() as $domain) { @@ -278,7 +275,7 @@ class WatchListController extends AbstractController } } - if (null !== $watchList->getWebhookDsn() && count($watchList->getWebhookDsn()) > (int) $this->getParameter('limit_max_watchlist_webhooks')) { + if (null !== $watchList->getWebhookDsn() && count($watchList->getWebhookDsn()) > (int)$this->getParameter('limit_max_watchlist_webhooks')) { $this->logger->notice('User {username} tried to update a Watchlist. The maximum number of webhooks has been reached.', [ 'username' => $user->getUserIdentifier(), ]); @@ -315,7 +312,7 @@ class WatchListController extends AbstractController * @throws ParseException * @throws EofException * @throws InvalidDataException - * @throws \Exception + * @throws Exception */ #[Route( path: '/api/watchlists/{token}/calendar', @@ -334,59 +331,9 @@ class WatchListController extends AbstractController /** @var Domain $domain */ foreach ($watchList->getDomains()->getIterator() as $domain) { - $attendees = []; - - /* @var DomainEntity $entity */ - foreach ($domain->getDomainEntities()->filter(fn (DomainEntity $domainEntity) => !$domainEntity->getDeleted())->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); + foreach ($domain->getEvents() as $event) { + $domain->addEvent($event); } - - /** @var DomainEvent $event */ - foreach ($domain->getEvents()->filter(fn (DomainEvent $e) => $e->getDate()->diff(new \DateTimeImmutable('now'))->y <= 10)->getIterator() as $event) { - $calendar->addEvent((new Event()) - ->setLastModified(new Timestamp($domain->getUpdatedAt())) - ->setStatus(EventStatus::CONFIRMED()) - ->setSummary($domain->getLdhName().': '.$event->getAction()) - ->addCategory(new Category($event->getAction())) - ->setAttendees($attendees) - ->setOccurrence(new SingleDay(new Date($event->getDate()))) - ); - } - - $expiresInDays = $domain->getExpiresInDays(); - - if (null === $expiresInDays) { - continue; - } - - $calendar->addEvent((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')) - ))) - ); } $calendarResponse = (new CalendarFactory())->createCalendar($calendar); @@ -401,7 +348,7 @@ class WatchListController extends AbstractController } /** - * @throws \Exception + * @throws Exception */ #[Route( path: '/api/tracked', @@ -422,7 +369,7 @@ class WatchListController extends AbstractController /** @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()); + $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; @@ -430,7 +377,7 @@ class WatchListController extends AbstractController } } - usort($domains, fn (Domain $d1, Domain $d2) => $d1->getExpiresInDays() - $d2->getExpiresInDays()); + usort($domains, fn(Domain $d1, Domain $d2) => $d1->getExpiresInDays() - $d2->getExpiresInDays()); return $domains; } @@ -441,7 +388,7 @@ class WatchListController extends AbstractController * @throws RedirectionExceptionInterface * @throws DecodingExceptionInterface * @throws ClientExceptionInterface - * @throws \JsonException + * @throws JsonException */ private function registerDomainsInWatchlist(string $content, array $groups): WatchList { @@ -454,7 +401,7 @@ class WatchListController extends AbstractController throw new BadRequestHttpException('Invalid payload: missing or invalid "domains" field.'); } - $domains = array_map(fn (string $d) => str_replace('/api/domains/', '', $d), $data['domains']); + $domains = array_map(fn(string $d) => str_replace('/api/domains/', '', $d), $data['domains']); foreach ($domains as $ldhName) { /** @var ?Domain $domain */ @@ -478,4 +425,140 @@ class WatchListController extends AbstractController return $watchList; } + + /** + * @throws Exception + */ + #[Route( + path: '/api/watchlists/{token}/rss/events', + name: 'watchlist_rss_events', + defaults: [ + '_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]); + + $feed = (new Feed()) + ->setLanguage('en') + ->setCopyright('Domain Watchdog makes this information available "as is," and does not guarantee its accuracy.') + ->setTitle('Domain events (' . $watchlist->getName().')') + ->setGenerator("Domain Watchdog - RSS Feed", null, "https://github.com/maelgangloff/domain-watchdog") + ->setDescription('The latest events for domain names in your Watchlist') + ->setLink($request->getSchemeAndHttpHost() . "/#/tracking/watchlist") + ->setFeedLink($request->getSchemeAndHttpHost() . "/api/watchlists/" . $token . '/rss/events', 'atom') + ->setDateCreated($watchlist->getCreatedAt()); + + /** @var Domain $domain */ + foreach ($watchlist->getDomains()->getIterator() as $domain) { + foreach ($this->getRssEventEntries($request->getSchemeAndHttpHost(), $domain) as $entry) { + $feed->addEntry($entry); + } + } + + return new Response($feed->export("atom"), Response::HTTP_OK, [ + 'Content-Type' => 'application/atom+xml; charset=utf-8', + ]); + } + + /** + * @throws Exception + */ + #[Route( + path: '/api/watchlists/{token}/rss/status', + name: 'watchlist_rss_status', + defaults: [ + '_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]); + + $feed = (new Feed()) + ->setLanguage('en') + ->setCopyright('Domain Watchdog makes this information available "as is," and does not guarantee its accuracy.') + ->setTitle('Domain EPP status (' . $watchlist->getName().')') + ->setGenerator("Domain Watchdog - RSS Feed", null, "https://github.com/maelgangloff/domain-watchdog") + ->setDescription('The latest changes to the EPP status of the domain names in your Watchlist') + ->setLink($request->getSchemeAndHttpHost() . "/#/tracking/watchlist") + ->setFeedLink($request->getSchemeAndHttpHost() . "/api/watchlists/" . $token . '/rss/status', 'atom') + ->setDateCreated($watchlist->getCreatedAt()); + + /** @var Domain $domain */ + foreach ($watchlist->getDomains()->getIterator() as $domain) { + foreach ($this->getRssStatusEntries($request->getSchemeAndHttpHost(), $domain) as $entry) { + $feed->addEntry($entry); + } + } + + return new Response($feed->export("atom"), Response::HTTP_OK, [ + 'Content-Type' => 'application/atom+xml; charset=utf-8', + ]); + } + + + /** + * @throws Exception + */ + private function getRssEventEntries(string $baseUrl, Domain $domain): array + { + $entries = []; + foreach ($domain->getEvents()->filter(fn (DomainEvent $e) => $e->getDate()->diff(new \DateTimeImmutable('now'))->y <= 10)->getIterator() as $event) { + $entries[] = (new Entry()) + ->setId('event-'.$event->getId()) + ->setTitle($domain->getLdhName() . ': ' . $event->getAction(). ' - event update') + ->setDescription("Domain name event") + ->setLink($baseUrl . "/#/search/domain/" . $domain->getLdhName()) + ->setContent($this->render('rss/event_entry.html.twig', [ + 'event' => $event + ])->getContent()) + ->setDateCreated($event->getDate()) + ->setDateModified($event->getDate()) + ->addAuthor(["name" => strtoupper($domain->getTld()->getTld()) . " Registry"]); + } + + return $entries; + } + + /** + * @throws Exception + */ + private function getRssStatusEntries(string $baseUrl, Domain $domain): array + { + $entries = []; + foreach ($domain->getDomainStatuses()->filter(fn (DomainStatus $e) => $e->getDate()->diff(new \DateTimeImmutable('now'))->y <= 10)->getIterator() as $domainStatus) { + $authors = [["name" => strtoupper($domain->getTld()->getTld()) . " Registry"]]; + /** @var string $status */ + foreach ([...$domainStatus->getAddStatus(), ...$domainStatus->getDeleteStatus()] as $status) { + if(str_starts_with($status, 'client')) { + $authors[] = ["name" => "Registrar"]; + break; + } + } + + $entries[] = (new Entry()) + ->setId('status-' . $domainStatus->getId()) + ->setTitle($domain->getLdhName() . ' - EPP status update') + ->setDescription("There has been a change in the EPP status of the domain name.") + ->setLink($baseUrl . "/#/search/domain/" . $domain->getLdhName()) + ->setContent($this->render('rss/status_entry.html.twig', [ + 'domainStatus' => $domainStatus + ])->getContent()) + ->setDateCreated($domainStatus->getCreatedAt()) + ->setDateModified($domainStatus->getDate()) + ->addCategory(["term" => $domain->getLdhName()]) + ->addCategory(["term" => strtoupper($domain->getTld()->getTld())]) + ->addAuthors($authors) + ; + } + + return $entries; + } + } diff --git a/src/Entity/Domain.php b/src/Entity/Domain.php index 742679e..9e252cc 100644 --- a/src/Entity/Domain.php +++ b/src/Entity/Domain.php @@ -7,10 +7,28 @@ use ApiPlatform\Metadata\Get; use App\Config\EventAction; use App\Controller\DomainRefreshController; use App\Repository\DomainRepository; +use DateInterval; +use DateMalformedIntervalStringException; +use DateTimeImmutable; 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 Exception; +use Laminas\Feed\Writer\Entry; +use Laminas\Feed\Writer\Source; +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; @@ -91,11 +109,11 @@ class Domain private Collection $nameservers; #[ORM\Column(type: Types::DATE_IMMUTABLE)] - private ?\DateTimeImmutable $createdAt; + private ?DateTimeImmutable $createdAt; #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] #[Groups(['domain:item', 'domain:list'])] - private ?\DateTimeImmutable $updatedAt; + private ?DateTimeImmutable $updatedAt; #[ORM\ManyToOne] #[ORM\JoinColumn(referencedColumnName: 'tld', nullable: false)] @@ -139,7 +157,7 @@ class Domain $this->domainEntities = new ArrayCollection(); $this->watchLists = new ArrayCollection(); $this->nameservers = new ArrayCollection(); - $this->updatedAt = new \DateTimeImmutable('now'); + $this->updatedAt = new DateTimeImmutable('now'); $this->createdAt = $this->updatedAt; $this->deleted = false; $this->domainStatuses = new ArrayCollection(); @@ -292,7 +310,7 @@ class Domain return $this; } - public function getUpdatedAt(): ?\DateTimeImmutable + public function getUpdatedAt(): ?DateTimeImmutable { return $this->updatedAt; } @@ -301,7 +319,7 @@ class Domain #[ORM\PreUpdate] public function updateTimestamps(): static { - $this->setUpdatedAt(new \DateTimeImmutable('now')); + $this->setUpdatedAt(new DateTimeImmutable('now')); if (null === $this->getCreatedAt()) { $this->setCreatedAt($this->getUpdatedAt()); } @@ -309,17 +327,17 @@ class Domain return $this; } - private function setUpdatedAt(?\DateTimeImmutable $updatedAt): void + private function setUpdatedAt(?DateTimeImmutable $updatedAt): void { $this->updatedAt = $updatedAt; } - public function getCreatedAt(): ?\DateTimeImmutable + public function getCreatedAt(): ?DateTimeImmutable { return $this->createdAt; } - private function setCreatedAt(?\DateTimeImmutable $createdAt): void + private function setCreatedAt(?DateTimeImmutable $createdAt): void { $this->createdAt = $createdAt; } @@ -352,7 +370,7 @@ class Domain * Determines if a domain name needs special attention. * These domain names are those whose last event was expiration or deletion. * - * @throws \Exception + * @throws Exception */ private function isToBeWatchClosely(): bool { @@ -363,10 +381,10 @@ class Domain /** @var DomainEvent[] $events */ $events = $this->getEvents() - ->filter(fn (DomainEvent $e) => !$e->getDeleted() && $e->getDate() <= new \DateTimeImmutable('now')) + ->filter(fn(DomainEvent $e) => !$e->getDeleted() && $e->getDate() <= new DateTimeImmutable('now')) ->toArray(); - usort($events, fn (DomainEvent $e1, DomainEvent $e2) => $e2->getDate() <=> $e1->getDate()); + usort($events, fn(DomainEvent $e1, DomainEvent $e2) => $e2->getDate() <=> $e1->getDate()); return !empty($events) && in_array($events[0]->getAction(), self::IMPORTANT_EVENTS); } @@ -377,11 +395,11 @@ class Domain * - 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 + * @throws Exception */ public function isToBeUpdated(bool $fromUser = true, bool $intensifyLastDay = false): bool { - $updatedAtDiff = $this->getUpdatedAt()->diff(new \DateTimeImmutable()); + $updatedAtDiff = $this->getUpdatedAt()->diff(new DateTimeImmutable()); if ($updatedAtDiff->days >= 7) { return true; @@ -477,9 +495,9 @@ class Domain } /** - * @throws \DateMalformedIntervalStringException + * @throws DateMalformedIntervalStringException */ - private function calculateDaysFromStatus(\DateTimeImmutable $now): ?int + private function calculateDaysFromStatus(DateTimeImmutable $now): ?int { $lastStatus = $this->getDomainStatuses()->last(); if (false === $lastStatus) { @@ -487,16 +505,16 @@ class Domain } if ($this->isPendingDelete() && ( - in_array('pending delete', $lastStatus->getAddStatus()) - || in_array('redemption period', $lastStatus->getDeleteStatus())) + in_array('pending delete', $lastStatus->getAddStatus()) + || in_array('redemption period', $lastStatus->getDeleteStatus())) ) { - return self::daysBetween($now, $lastStatus->getCreatedAt()->add(new \DateInterval('P'. 5 .'D'))); + 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 self::daysBetween($now, $lastStatus->getCreatedAt()->add(new DateInterval('P' . (30 + 5) . 'D'))); } return null; @@ -521,7 +539,7 @@ class Domain } */ - private static function daysBetween(\DateTimeImmutable $start, \DateTimeImmutable $end): int + private static function daysBetween(DateTimeImmutable $start, DateTimeImmutable $end): int { $interval = $start->setTime(0, 0)->diff($end->setTime(0, 0)); @@ -542,7 +560,7 @@ class Domain } /** - * @throws \Exception + * @throws Exception */ private function getRelevantDates(): array { @@ -561,7 +579,7 @@ class Domain } /** - * @throws \Exception + * @throws Exception */ #[Groups(['domain:item', 'domain:list'])] public function getExpiresInDays(): ?int @@ -570,11 +588,11 @@ class Domain return null; } - $now = new \DateTimeImmutable(); + $now = new DateTimeImmutable(); [$expiredAt, $deletedAt] = $this->getRelevantDates(); if ($expiredAt) { - $guess = self::daysBetween($now, $expiredAt->add(new \DateInterval('P'.(45 + 30 + 5).'D'))); + $guess = self::daysBetween($now, $expiredAt->add(new DateInterval('P' . (45 + 30 + 5) . 'D'))); } if ($deletedAt) { @@ -583,7 +601,7 @@ class Domain return 0; } - $guess = self::daysBetween($now, $deletedAt->add(new \DateInterval('P'. 30 .'D'))); + $guess = self::daysBetween($now, $deletedAt->add(new DateInterval('P' . 30 . 'D'))); } return self::returnExpiresIn([ @@ -591,4 +609,68 @@ class Domain $this->calculateDaysFromStatus($now), ]); } + + /** + * @throws ParseException + * @throws EofException + * @throws InvalidDataException + * @throws Exception + */ + public function getDomainCalendarEvents(): array + { + $events = []; + $attendees = []; + + /* @var DomainEntity $entity */ + foreach ($this->getDomainEntities()->filter(fn (DomainEntity $domainEntity) => !$domainEntity->getDeleted())->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 ($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; + } } diff --git a/src/Entity/WatchList.php b/src/Entity/WatchList.php index d7acfd0..c7e10d8 100644 --- a/src/Entity/WatchList.php +++ b/src/Entity/WatchList.php @@ -92,6 +92,52 @@ use Symfony\Component\Uid\Uuid; new Delete( security: 'object.user == user' ), + new Get( + routeName: 'watchlist_rss_status', + defaults: ['_format' => 'xml'], + openapiContext: [ + 'responses' => [ + '200' => [ + 'description' => 'Domain EPP status RSS feed', + 'content' => [ + 'application/atom+xml' => [ + 'schema' => [ + 'type' => 'string', + 'format' => 'text', + ], + ], + ], + ], + ], + ], + read: false, + deserialize: false, + serialize: false, + name: 'rss_status' + ), + new Get( + routeName: 'watchlist_rss_events', + defaults: ['_format' => 'xml'], + openapiContext: [ + 'responses' => [ + '200' => [ + 'description' => 'Domain events RSS feed', + 'content' => [ + 'application/atom+xml' => [ + 'schema' => [ + 'type' => 'string', + 'format' => 'text', + ], + ], + ], + ], + ], + ], + read: false, + deserialize: false, + serialize: false, + name: 'rss_events' + ), ], )] class WatchList diff --git a/templates/rss/event_entry.html.twig b/templates/rss/event_entry.html.twig new file mode 100644 index 0000000..555b892 --- /dev/null +++ b/templates/rss/event_entry.html.twig @@ -0,0 +1,8 @@ +
+ A new event has been recorded for this domain: +
+ ++ The EPP status of this domain name has changed: +
+ +{% if domainStatus.addStatus is not empty %} +Added statuses:
+Deleted statuses:
+Change date: {{ domainStatus.date|date('Y-m-d H:i:s') }}