feat: start Atom feed implementation

This commit is contained in:
Maël Gangloff
2025-04-27 12:13:06 +02:00
parent c940fba01a
commit c0dcb2f848
8 changed files with 561 additions and 117 deletions

View File

@@ -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",

202
composer.lock generated
View File

@@ -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",

View File

@@ -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 }

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -0,0 +1,8 @@
<p>
A new event has been recorded for this domain:
</p>
<ul>
<li><strong>Action:</strong> {{ event.action }}</li>
<li><strong>Date:</strong> {{ event.date|date('Y-m-d H:i:s') }}</li>
</ul>

View File

@@ -0,0 +1,23 @@
<p>
The EPP status of this domain name has changed:
</p>
{% if domainStatus.addStatus is not empty %}
<p><strong>Added statuses:</strong></p>
<ul>
{% for status in domainStatus.addStatus %}
<li>{{ status }}</li>
{% endfor %}
</ul>
{% endif %}
{% if domainStatus.deleteStatus is not empty %}
<p><strong>Deleted statuses:</strong></p>
<ul>
{% for status in domainStatus.deleteStatus %}
<li>{{ status }}</li>
{% endfor %}
</ul>
{% endif %}
<p><strong>Change date:</strong> {{ domainStatus.date|date('Y-m-d H:i:s') }}</p>