From 6e1402cd782332a99bd61b345e6a2749e665bf84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Thu, 9 Oct 2025 20:05:08 +0200 Subject: [PATCH] More media. Optimize fetching of events by id - test run. --- config/services.yaml | 3 + docker/cron/media_discovery.sh | 15 +- src/Command/CacheMediaDiscoveryCommand.php | 2 +- .../MagazineAdminController.php | 20 +- src/Controller/ArticleController.php | 11 +- src/Controller/DefaultController.php | 24 +- src/Controller/MediaDiscoveryController.php | 4 +- src/Service/Nip05VerificationService.php | 4 +- src/Service/NostrClient.php | 42 ++-- src/Service/NostrLinkParser.php | 2 +- src/Service/RedisCacheService.php | 2 +- src/Twig/Components/SearchComponent.php | 4 +- src/Twig/Filters.php | 16 +- .../NostrEventRenderer.php | 2 +- .../NostrSchemeParser.php | 26 ++- src/Util/NostrPhp/TweakedRequest.php | 168 ++++++++++++++ .../components/ReadingListDropdown.html.twig | 2 +- templates/event/_kind20_picture.html.twig | 67 ++++-- templates/event/index.html.twig | 217 ++++++------------ templates/pages/author.html.twig | 6 + 20 files changed, 419 insertions(+), 218 deletions(-) create mode 100644 src/Util/NostrPhp/TweakedRequest.php diff --git a/config/services.yaml b/config/services.yaml index 5a2397f..b38ef36 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -69,3 +69,6 @@ services: App\Command\NostrEventFromYamlDefinitionCommand: arguments: $itemPersister: '@fos_elastica.object_persister.articles' + + App\Util\NostrPhp\TweakedRequest: ~ + swentel\nostr\Request\Request: '@App\Util\NostrPhp\TweakedRequest' diff --git a/docker/cron/media_discovery.sh b/docker/cron/media_discovery.sh index a27f11e..94e404e 100644 --- a/docker/cron/media_discovery.sh +++ b/docker/cron/media_discovery.sh @@ -2,5 +2,18 @@ set -e export PATH="/usr/local/bin:/usr/bin:/bin" -# Run Symfony commands sequentially +LOG_PREFIX="[media_discovery.sh]" +TIMESTAMP() { date '+%Y-%m-%d %H:%M:%S'; } + +# Log start +echo "$(TIMESTAMP) $LOG_PREFIX STARTING media discovery cache update" >&2 + +# Run Symfony command php /var/www/html/bin/console app:cache-media-discovery +EXIT_CODE=$? + +if [ $EXIT_CODE -eq 0 ]; then + echo "$(TIMESTAMP) $LOG_PREFIX FINISHED successfully (exit code: $EXIT_CODE)" >&2 +else + echo "$(TIMESTAMP) $LOG_PREFIX ERROR (exit code: $EXIT_CODE)" >&2 +fi diff --git a/src/Command/CacheMediaDiscoveryCommand.php b/src/Command/CacheMediaDiscoveryCommand.php index 50ad6f4..b447706 100644 --- a/src/Command/CacheMediaDiscoveryCommand.php +++ b/src/Command/CacheMediaDiscoveryCommand.php @@ -21,7 +21,7 @@ use Symfony\Contracts\Cache\CacheInterface; )] class CacheMediaDiscoveryCommand extends Command { - private const int CACHE_TTL = 32500; // 9ish hours in seconds + private const CACHE_TTL = 32500; // 9ish hours in seconds // Hardcoded topic to hashtag mapping (same as controller) private const TOPIC_HASHTAGS = [ diff --git a/src/Controller/Administration/MagazineAdminController.php b/src/Controller/Administration/MagazineAdminController.php index 78a0c32..29d5b57 100644 --- a/src/Controller/Administration/MagazineAdminController.php +++ b/src/Controller/Administration/MagazineAdminController.php @@ -103,11 +103,6 @@ class MagazineAdminController extends AbstractController // 1) Get magazine events directly from database using indexed queries $magazineEvents = $this->getMagazineEvents($em); - // Fallback: if no magazines found in DB, try Redis as backup - if (empty($magazineEvents)) { - return $this->getFallbackMagazinesFromRedis($redis, $redisCache, $em); - } - // 2) Get all category events in one query $categoryCoordinates = $this->extractCategoryCoordinates($magazineEvents); $categoryEvents = $this->getCategoryEventsByCoordinates($em, $categoryCoordinates); @@ -139,12 +134,22 @@ class MagazineAdminController extends AbstractController private function filterTopLevelMagazines(array $magazineEvents): array { $topLevelMagazines = []; + // Sort by createdAt descending to keep latest in case of duplicates + usort($magazineEvents, fn($a, $b) => $b->getCreatedAt() <=> $a->getCreatedAt()); + $seenSlugs = []; foreach ($magazineEvents as $event) { $hasSubIndexes = false; $hasDirectArticles = false; - + $isOldDuplicate = false; foreach ($event->getTags() as $tag) { + // Keep a list of slugs, so you can skip if already there + if ($tag[0] === 'd' && isset($tag[1])) { + if (in_array($tag[1], $seenSlugs)) { + $isOldDuplicate = true; + } + $seenSlugs[] = $tag[1]; + } if ($tag[0] === 'a' && isset($tag[1])) { $coord = $tag[1]; @@ -161,11 +166,10 @@ class MagazineAdminController extends AbstractController // Only include magazines that have sub-indexes but no direct articles // This identifies top-level magazines that organize other indexes - if ($hasSubIndexes && !$hasDirectArticles) { + if ($hasSubIndexes && !$hasDirectArticles && !$isOldDuplicate) { $topLevelMagazines[] = $event; } } - return $topLevelMagazines; } diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 4a97751..e9c562c 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -97,10 +97,10 @@ class ArticleController extends AbstractController $cacheKey = 'article_' . $article->getEventId(); $cacheItem = $articlesCache->getItem($cacheKey); - if (!$cacheItem->isHit()) { + //if (!$cacheItem->isHit()) { $cacheItem->set($converter->convertToHTML($article->getContent())); $articlesCache->save($cacheItem); - } + //} $key = new Key(); $npub = $key->convertPublicKeyToBech32($article->getPubkey()); @@ -155,7 +155,7 @@ class ArticleController extends AbstractController usort($articles, function ($a, $b) { return $b->getCreatedAt() <=> $a->getCreatedAt(); }); - $article = array_pop($articles); + $article = array_shift($articles); } if ($article->getPubkey() === null) { @@ -186,6 +186,7 @@ class ArticleController extends AbstractController Request $request, EntityManagerInterface $entityManager, NostrClient $nostrClient, + CacheItemPoolInterface $articlesCache, CsrfTokenManagerInterface $csrfTokenManager ): JsonResponse { try { @@ -256,6 +257,10 @@ class ArticleController extends AbstractController $entityManager->persist($article); $entityManager->flush(); + // Clear relevant caches + $cacheKey = 'article_' . $article->getEventId(); + $articlesCache->delete($cacheKey); + // Optionally publish to Nostr relays try { // Get user's relays or use default ones diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index 41100c8..636aab7 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -12,6 +12,7 @@ use App\Util\CommonMark\Converter; use Doctrine\ORM\EntityManagerInterface; use Elastica\Collapse; use Elastica\Query; +use Elastica\Query\BoolQuery; use Elastica\Query\Terms; use Exception; use FOS\ElasticaBundle\Finder\FinderInterface; @@ -19,6 +20,7 @@ use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; use swentel\nostr\Key\Key; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\DependencyInjection\Tests\Compiler\K; use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; @@ -56,12 +58,26 @@ class DefaultController extends AbstractController $cacheKey = 'latest_articles_list_' . $env ; // Use env to differentiate cache between environments $cacheItem = $articlesCache->getItem($cacheKey); + $key = new Key(); + $excludedPubkeys = [ + $key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), // Discreet Log (News Bot) + $key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), // Bitcoin Magazine (News Bot) + $key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), // No Bullshit Bitcoin (News Bot) + $key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), // TFTC (News Bot) + $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz (Just annoying) + $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace - feed πš‹πš˜πš (Just annoying) + ]; + if (!$cacheItem->isHit()) { // Query all articles and sort by created_at descending - $query = new Query(); + $boolQuery = new BoolQuery(); + $boolQuery->addMustNot(new Query\Terms('pubkey', $excludedPubkeys)); + + $query = new Query($boolQuery); $query->setSize(50); $query->setSort(['createdAt' => ['order' => 'desc']]); + // Use collapse to deduplicate by slug field $collapse = new Collapse(); $collapse->setFieldname('slug'); @@ -134,7 +150,8 @@ class DefaultController extends AbstractController // Query the database for the category event by slug using native SQL $sql = "SELECT e.* FROM event e WHERE e.tags::jsonb @> ?::jsonb - LIMIT 1"; + ORDER BY e.created_at DESC + "; $conn = $entityManager->getConnection(); $result = $conn->executeQuery($sql, [ @@ -143,6 +160,7 @@ class DefaultController extends AbstractController $eventData = $result->fetchAssociative(); + if ($eventData === false) { throw new Exception('Category not found'); } @@ -166,6 +184,8 @@ class DefaultController extends AbstractController } } + dump($tags);die; + if (!empty($coordinates)) { // Extract slugs for elasticsearch query $slugs = array_map(function($coordinate) { diff --git a/src/Controller/MediaDiscoveryController.php b/src/Controller/MediaDiscoveryController.php index 8208cdc..e35d6a3 100644 --- a/src/Controller/MediaDiscoveryController.php +++ b/src/Controller/MediaDiscoveryController.php @@ -12,8 +12,8 @@ use Symfony\Contracts\Cache\ItemInterface; class MediaDiscoveryController extends AbstractController { - private const int CACHE_TTL = 10800; // 3 hours in seconds - private const int MAX_DISPLAY_EVENTS = 42; + private const CACHE_TTL = 10800; // 3 hours in seconds + private const MAX_DISPLAY_EVENTS = 42; // Hardcoded topic to hashtag mapping private const TOPIC_HASHTAGS = [ diff --git a/src/Service/Nip05VerificationService.php b/src/Service/Nip05VerificationService.php index 386a4d5..dcc40d4 100644 --- a/src/Service/Nip05VerificationService.php +++ b/src/Service/Nip05VerificationService.php @@ -8,8 +8,8 @@ use Symfony\Contracts\Cache\ItemInterface; readonly class Nip05VerificationService { - private const int CACHE_TTL = 3600; // 1 hour - private const int REQUEST_TIMEOUT = 5; // 5 seconds + private const CACHE_TTL = 3600; // 1 hour + private const REQUEST_TIMEOUT = 5; // 5 seconds public function __construct( private CacheInterface $redisCache, diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index d51e9f1..faa241d 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -5,6 +5,7 @@ namespace App\Service; use App\Entity\Article; use App\Enum\KindsEnum; use App\Factory\ArticleFactory; +use App\Util\NostrPhp\TweakedRequest; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; use nostriphant\NIP19\Data; @@ -26,7 +27,7 @@ class NostrClient /** * List of reputable relays in descending order of reputation */ - private const array REPUTABLE_RELAYS = [ + private const REPUTABLE_RELAYS = [ 'wss://theforest.nostr1.com', 'wss://relay.damus.io', 'wss://relay.primal.net', @@ -44,8 +45,7 @@ class NostrClient { $this->defaultRelaySet = new RelaySet(); $this->defaultRelaySet->addRelay(new Relay('wss://theforest.nostr1.com')); // public aggregator relay - $this->defaultRelaySet->addRelay(new Relay('wss://relay.damus.io')); // public aggregator relay - $this->defaultRelaySet->addRelay(new Relay('wss://relay.primal.net')); // public aggregator relay + $this->defaultRelaySet->addRelay(new Relay('wss://nostr.land')); // public aggregator relay } /** @@ -271,6 +271,26 @@ class NostrClient { $this->logger->info('Getting event by ID', ['event_id' => $eventId, 'relays' => $relays]); + // Merge relays with reputable ones + $allRelays = array_unique(array_merge($relays, self::REPUTABLE_RELAYS)); + // Loop over reputable relays and bail as soon as you get a valid event back + foreach ($allRelays as $reputableRelay) { + $this->logger->info('Trying reputable relay first', ['relay' => $reputableRelay]); + $request = $this->createNostrRequest( + kinds: [], + filters: ['ids' => [$eventId], 'limit' => 1], + relaySet: $this->createRelaySet([$reputableRelay]), + stopGap: $eventId + ); + $events = $this->processResponse($request->send(), function($event) { + $this->logger->debug('Received event', ['event' => $event]); + return $event; + }); + if (!empty($events)) { + return $events[0]; + } + } + // Use provided relays or default if empty $relaySet = empty($relays) ? $this->defaultRelaySet : $this->createRelaySet($relays); @@ -355,18 +375,6 @@ class NostrClient return !empty($events) ? $events[0] : null; } - /** - * Fetch a note by its ID - * - * @param string $noteId The note ID - * @return object|null The note or null if not found - * @throws \Exception - */ - public function getNoteById(string $noteId): ?object - { - return $this->getEventById($noteId); - } - private function saveLongFormContent(mixed $filtered): void { foreach ($filtered as $wrapper) { @@ -862,7 +870,7 @@ class NostrClient return $articlesMap; } - private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null): Request + private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null, $stopGap = null ): TweakedRequest { $subscription = new Subscription(); $filter = new Filter(); @@ -884,7 +892,7 @@ class NostrClient } $requestMessage = new RequestMessage($subscription->getId(), [$filter]); - return new Request($relaySet ?? $this->defaultRelaySet, $requestMessage); + return (new TweakedRequest($relaySet ?? $this->defaultRelaySet, $requestMessage))->stopOnEventId($stopGap); } private function processResponse(array $response, callable $eventHandler): array diff --git a/src/Service/NostrLinkParser.php b/src/Service/NostrLinkParser.php index 62e3974..dab92ab 100644 --- a/src/Service/NostrLinkParser.php +++ b/src/Service/NostrLinkParser.php @@ -7,7 +7,7 @@ use Psr\Log\LoggerInterface; readonly class NostrLinkParser { - private const string NOSTR_LINK_PATTERN = '/(?:nostr:)(nevent1[a-z0-9]+|naddr1[a-z0-9]+|nprofile1[a-z0-9]+|note1[a-z0-9]+|npub1[a-z0-9]+)/'; + private const NOSTR_LINK_PATTERN = '/(?:nostr:)(nevent1[a-z0-9]+|naddr1[a-z0-9]+|nprofile1[a-z0-9]+|note1[a-z0-9]+|npub1[a-z0-9]+)/'; private const URL_PATTERN = '/https?:\/\/[\w\-\.\?\,\'\/\\\+&%@\?\$#_=:\(\)~;]+/i'; diff --git a/src/Service/RedisCacheService.php b/src/Service/RedisCacheService.php index 57a7b3b..0dd9151 100644 --- a/src/Service/RedisCacheService.php +++ b/src/Service/RedisCacheService.php @@ -248,7 +248,7 @@ readonly class RedisCacheService usort($nzines, function ($a, $b) { return $b->getCreatedAt() <=> $a->getCreatedAt(); }); - $nzine = array_pop($nzines); + $nzine = array_shift($nzines); $this->logger->info('Magazine lookup', ['mag' => $slug, 'found' => json_encode($nzine)]); diff --git a/src/Twig/Components/SearchComponent.php b/src/Twig/Components/SearchComponent.php index 80d599d..ccef292 100644 --- a/src/Twig/Components/SearchComponent.php +++ b/src/Twig/Components/SearchComponent.php @@ -48,8 +48,8 @@ final class SearchComponent #[LiveProp(writable: true)] public bool $selectMode = false; - private const string SESSION_KEY = 'last_search_results'; - private const string SESSION_QUERY_KEY = 'last_search_query'; + private const SESSION_KEY = 'last_search_results'; + private const SESSION_QUERY_KEY = 'last_search_query'; public function __construct( private readonly FinderInterface $finder, diff --git a/src/Twig/Filters.php b/src/Twig/Filters.php index d6af724..8f653a4 100644 --- a/src/Twig/Filters.php +++ b/src/Twig/Filters.php @@ -4,8 +4,9 @@ declare(strict_types=1); namespace App\Twig; +use BitWasp\Bech32\Exception\Bech32Exception; +use swentel\nostr\Nip19\Nip19Helper; use Twig\Extension\AbstractExtension; -use Twig\TwigFunction; use Twig\TwigFilter; class Filters extends AbstractExtension @@ -15,7 +16,8 @@ class Filters extends AbstractExtension return [ new TwigFilter('shortenNpub', [$this, 'shortenNpub']), new TwigFilter('linkify', [$this, 'linkify'], ['is_safe' => ['html']]), - new TwigFilter('mentionify', [$this, 'mentionify'], ['is_safe' => ['html']]) + new TwigFilter('mentionify', [$this, 'mentionify'], ['is_safe' => ['html']]), + new TwigFilter('nEncode', [$this, 'nEncode']), ]; } @@ -59,4 +61,14 @@ class Filters extends AbstractExtension $text ); } + + public function nEncode(string $eventId): string + { + $nip19 = new Nip19Helper(); + try { + return $nip19->encodeNote($eventId); + } catch (Bech32Exception) { + return $eventId; // Return original if encoding fails + } + } } diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php b/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php index 020df24..38dc590 100644 --- a/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrEventRenderer.php @@ -16,7 +16,7 @@ class NostrEventRenderer implements NodeRendererInterface throw new \InvalidArgumentException('Incompatible inline node type: ' . get_class($node)); } - if ($node->getType() === 'nevent') { + if ($node->getType() === 'nevent' || $node->getType() === 'note') { // Construct the local link URL from the special part $url = '/e/' . $node->getSpecial(); } else if ($node->getType() === 'naddr') { diff --git a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php index f86a999..3623611 100644 --- a/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php +++ b/src/Util/CommonMark/NostrSchemeExtension/NostrSchemeParser.php @@ -10,6 +10,7 @@ use League\CommonMark\Parser\InlineParserContext; use nostriphant\NIP19\Bech32; use nostriphant\NIP19\Data\NAddr; use nostriphant\NIP19\Data\NEvent; +use nostriphant\NIP19\Data\Note; use nostriphant\NIP19\Data\NProfile; use nostriphant\NIP19\Data\NPub; use Twig\Environment; @@ -32,7 +33,9 @@ class NostrSchemeParser implements InlineParserInterface public function getMatchDefinition(): InlineParserMatch { - return InlineParserMatch::regex('nostr:[0-9a-zA-Z]+'); + return InlineParserMatch::regex( + 'nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[^\\s<>()\\[\\]{}"\'`.,;:!?]*' + ); } public function parse(InlineParserContext $inlineContext): bool @@ -63,6 +66,25 @@ class NostrSchemeParser implements InlineParserInterface $decodedProfile = $decoded->data; $inlineContext->getContainer()->appendChild(new NostrMentionLink(null, $decodedProfile->pubkey)); break; + case 'note': + /** @var Note $decodedEvent */ + $decodedEvent = $decoded->data; + // Fetch the actual event data using the same logic as EventController + $event = $this->nostrClient->getEventById($decodedEvent->data); + // If note is kind 20 + // Render the embedded picture card + if (!$event || $event->kind !== 20) { + // Fallback to simple link if event not found or not kind 20 + $inlineContext->getContainer()->appendChild(new NostrSchemeData('note', $bechEncoded, [], null, null)); + break; + } + $pictureCardHtml = $this->twig->render('/event/_kind20_picture.html.twig', [ + 'event' => $event, + 'embed' => true + ]); + // Create a new node type for embedded HTML content + $inlineContext->getContainer()->appendChild(new NostrEmbeddedCard($pictureCardHtml)); + break; case 'nevent': /** @var NEvent $decodedEvent */ $decodedEvent = $decoded->data; @@ -109,7 +131,7 @@ class NostrSchemeParser implements InlineParserInterface } } catch (\Exception $e) { - // dump($e->getMessage()); + dump($e->getMessage()); return false; } diff --git a/src/Util/NostrPhp/TweakedRequest.php b/src/Util/NostrPhp/TweakedRequest.php new file mode 100644 index 0000000..53a49ea --- /dev/null +++ b/src/Util/NostrPhp/TweakedRequest.php @@ -0,0 +1,168 @@ +relays = $relay; + } else { + $set = new RelaySet(); + $set->setRelays([$relay]); + $this->relays = $set; + } + $this->payload = $message->generate(); + } + + public function stopOnEventId(?string $hexId): self + { + $this->stopOnEventId = $hexId; + return $this; + } + + /** @return array */ + public function send(): array + { + $result = []; + + foreach ($this->relays->getRelays() as $relay) { + $this->responses = []; // reset per relay + try { + if (!$relay->isConnected()) { + $relay->connect(); + } + + $client = $relay->getClient(); + $client->setTimeout(15); // seconds per receive call (keep it small if you want responsiveness) + + // Send subscription payload + $client->text($this->payload); + + // Loop until: first match, EOSE/CLOSED/ERROR, or socket ends + while ($resp = $client->receive()) { + if ($resp instanceof \WebSocket\Message\Ping) { + $client->text((new Pong())->getPayload()); + continue; + } + if (!$resp instanceof Text) { + continue; + } + + $decoded = json_decode($resp->getContent()); + $relayResponse = RelayResponse::create($decoded); + $this->responses[] = $relayResponse; + + // Early exit on matching EVENT + if ($relayResponse->type === 'EVENT') { + // Safest: decode again to array to grab [ "EVENT", subId, event ] + $raw = json_decode($resp->getContent(), true); + $sub = $raw[1] ?? null; + $evt = $raw[2] ?? null; + $evtId = is_array($evt) ? ($evt['id'] ?? null) : null; + + if ($this->stopOnEventId !== null && $evtId === $this->stopOnEventId) { + if ($sub) { + $this->sendClose($client, $sub); + } + $relay->disconnect(); + $result[$relay->getUrl()] = $this->responses; + // stop the outer foreach too (we're done) + return $result; + } + } + + // Tear-down conditions + if ($relayResponse->type === 'EOSE') { + $sub = $relayResponse->subscriptionId ?? null; + if ($sub) { + $this->sendClose($client, $sub); + } + $relay->disconnect(); + break; + } + + if ($relayResponse->type === 'NOTICE' && str_starts_with($relayResponse->message ?? '', 'ERROR:')) { + $relay->disconnect(); + break; + } + + if ($relayResponse->type === 'CLOSED') { + $relay->disconnect(); + break; + } + + // NIP-42: if relay requests AUTH, perform it once, then continue. + if ($relayResponse->type === 'OK' && isset($relayResponse->message) && str_starts_with($relayResponse->message, 'auth-required:')) { + $this->performAuth($relay, $client); + // After AUTH, re-send the original payload + $client->text($this->payload); + // continue loop + } + } + + // Save what we got for this relay + $result[$relay->getUrl()] = $this->responses; + } catch (\Throwable $e) { + $result[$relay->getUrl()][] = ['ERROR', '', false, $e->getMessage()]; + // best-effort disconnect + try { $relay->disconnect(); } catch (\Throwable) {} + } + } + + return $result; + } + + private function sendClose(WsClient $client, string $subscriptionId): void + { + try { + $close = new CloseMessage($subscriptionId); + $client->text($close->generate()); + } catch (\Throwable) {} + } + + /** Very lightweight NIP-42 auth flow: sign challenge and send AUTH + resume. */ + private function performAuth(Relay $relay, WsClient $client): void + { + // NOTE: This reuses the vendor types, but uses a dummy secret. You should inject your real sec key. + if (!isset($_SESSION['challenge'])) { + return; + } + try { + $authEvent = new AuthEvent($relay->getUrl(), $_SESSION['challenge']); + $sec = '0000000000000000000000000000000000000000000000000000000000000001'; // TODO inject your real sec + (new Sign())->signEvent($authEvent, $sec); + + $authMsg = new AuthMessage($authEvent); + $client->text($authMsg->generate()); + } catch (\Throwable) { + // ignore and continue; some relays won’t require it + } + } +} diff --git a/templates/components/ReadingListDropdown.html.twig b/templates/components/ReadingListDropdown.html.twig index bf1ff38..2afcc79 100644 --- a/templates/components/ReadingListDropdown.html.twig +++ b/templates/components/ReadingListDropdown.html.twig @@ -16,7 +16,7 @@ data-reading-list-dropdown-target="dropdown" data-action="click->reading-list-dropdown#toggleDropdown" aria-expanded="false"> - πŸ“š Add to Reading List + Add to Reading List