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 3906b4e..7ddb272 100644 --- a/src/Controller/WatchListController.php +++ b/src/Controller/WatchListController.php @@ -3,29 +3,23 @@ namespace App\Controller; 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\Repository\WatchListRepository; use Doctrine\Common\Collections\Collection; -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 Laminas\Feed\Writer\Entry; +use Laminas\Feed\Writer\Feed; 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\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -76,59 +70,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->getDomainCalendarEvents() as $event) { + $calendar->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); @@ -176,4 +120,137 @@ class WatchListController extends AbstractController return $domains; } + + /** + * @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($baseUrl.'/api/domains/'.$domain->getLdhName().'#events-'.$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()) + ->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($baseUrl.'/api/domains/'.$domain->getLdhName().'#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 7511f8b..3b15241 100644 --- a/src/Entity/Domain.php +++ b/src/Entity/Domain.php @@ -13,6 +13,18 @@ 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; @@ -633,4 +645,70 @@ class Domain return $this; } + + /** + * @return Event[] + * + * @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 bb125ed..b3f3980 100644 --- a/src/Entity/WatchList.php +++ b/src/Entity/WatchList.php @@ -97,6 +97,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') }}