From 19c49ba550de40559d4aa842832d7f71cccec8e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Tue, 14 Oct 2025 19:19:26 +0200 Subject: [PATCH] Simplify --- config/packages/cache.yaml | 2 +- src/Controller/ArticleController.php | 16 ++- src/Controller/AuthorController.php | 23 ---- src/Service/NostrClient.php | 81 +++++------ src/Service/RedisCacheService.php | 49 +------ src/Util/CommonMark/Converter.php | 149 +++++++++++++++++++- templates/pages/article.html.twig | 4 + templates/pages/author-about.html.twig | 181 ------------------------- templates/pages/author-media.html.twig | 4 +- templates/pages/author.html.twig | 3 - 10 files changed, 208 insertions(+), 304 deletions(-) delete mode 100644 templates/pages/author-about.html.twig diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml index f7d2f2e..169282b 100644 --- a/config/packages/cache.yaml +++ b/config/packages/cache.yaml @@ -19,7 +19,7 @@ framework: articles.cache: adapter: cache.adapter.redis provider: Redis - default_lifetime: 3600 + default_lifetime: 0 npub.cache: adapter: cache.adapter.redis provider: Redis diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 882df4c..c7f60a0 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -97,12 +97,14 @@ class ArticleController extends AbstractController $article = $articles[0]; - $cacheKey = 'article_' . $article->getEventId(); - $cacheItem = $articlesCache->getItem($cacheKey); - if (!$cacheItem->isHit()) { - $cacheItem->set($converter->convertToHTML($article->getContent())); - $articlesCache->save($cacheItem); - } + $parsed = $converter->convertToHTML($article->getContent()); + +// $cacheKey = 'article_' . $article->getEventId(); +// $cacheItem = $articlesCache->getItem($cacheKey); +// if (!$cacheItem->isHit()) { +// $cacheItem->set($converter->convertToHTML($article->getContent())); +// $articlesCache->save($cacheItem); +// } $key = new Key(); $npub = $key->convertPublicKeyToBech32($article->getPubkey()); @@ -126,7 +128,7 @@ class ArticleController extends AbstractController 'article' => $article, 'author' => $author, 'npub' => $npub, - 'content' => $cacheItem->get(), + 'content' => $parsed, //$cacheItem->get(), 'canEdit' => $canEdit, 'canonical' => $canonical ]); diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index b8c7d8e..a861c91 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -94,29 +94,6 @@ class AuthorController extends AbstractController ]); } - /** - * @throws Exception - */ - #[Route('/p/{npub}/about', name: 'author-about', requirements: ['npub' => '^npub1.*'])] - public function about($npub, RedisCacheService $redisCacheService): Response - { - $keys = new Key(); - $pubkey = $keys->convertToHex($npub); - - // Get metadata with raw event for debugging - $profileData = $redisCacheService->getMetadataWithRawEvent($npub); - $author = $profileData['metadata']; - $rawEvent = $profileData['rawEvent']; - - return $this->render('pages/author-about.html.twig', [ - 'author' => $author, - 'npub' => $npub, - 'pubkey' => $pubkey, - 'rawEvent' => $rawEvent, - 'is_author_profile' => true, - ]); - } - /** * @throws Exception * @throws ExceptionInterface diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index ea7a013..afe931e 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -108,6 +108,7 @@ class NostrClient { $relaySet = $this->defaultRelaySet; $relaySet->addRelay(new Relay('wss://profiles.nostr1.com')); // profile aggregator + $relaySet->addRelay(new Relay('wss://purplepag.es')); // profile aggregator $this->logger->info('Getting metadata for pubkey ' . $pubkey ); $request = $this->createNostrRequest( kinds: [KindsEnum::METADATA], @@ -131,45 +132,6 @@ class NostrClient return $events[0]; } - public function getNpubLongForm($npub): void - { - $subscription = new Subscription(); - $subscriptionId = $subscription->setId(); - $filter = new Filter(); - $filter->setKinds([KindsEnum::LONGFORM]); - $filter->setAuthors([$npub]); - $filter->setSince(strtotime('-6 months')); // too much? - $requestMessage = new RequestMessage($subscriptionId, [$filter]); - - // if user is logged in, use their settings - /* @var $user */ - $user = $this->tokenStorage->getToken()?->getUser(); - $relays = $this->defaultRelaySet; - if ($user && $user->getRelays()) { - $relays = new RelaySet(); - foreach ($user->getRelays() as $relayArr) { - if ($relayArr[2] == 'write') { - $relays->addRelay(new Relay($relayArr[1])); - } - } - } - - $request = new Request($relays, $requestMessage); - - $response = $request->send(); - // response is an n-dimensional array, where n is the number of relays in the set - // check that response has events in the results - foreach ($response as $relayRes) { - $filtered = array_filter($relayRes, function ($item) { - return $item->type === 'EVENT'; - }); - if (count($filtered) > 0) { - $this->saveLongFormContent($filtered); - } - } - // TODO handle relays that require auth - } - public function publishEvent(Event $event, array $relays): array { $eventMessage = new EventMessage($event); @@ -323,6 +285,47 @@ class NostrClient return $events[0]; } + /** + * Get multiple events by their IDs + * + * @param array $eventIds Array of event IDs + * @param array $relays Optional array of relay URLs to query + * @return array Array of events indexed by ID + * @throws \Exception + */ + public function getEventsByIds(array $eventIds, array $relays = []): array + { + if (empty($eventIds)) { + return []; + } + + $this->logger->info('Getting events by IDs', ['event_ids' => $eventIds, 'relays' => $relays]); + + // Use provided relays or default if empty + $relaySet = empty($relays) ? $this->defaultRelaySet : $this->createRelaySet($relays); + + // Create request using the helper method + $request = $this->createNostrRequest( + kinds: [], + filters: ['ids' => $eventIds], + relaySet: $relaySet + ); + + // Process the response + $events = $this->processResponse($request->send(), function($event) { + $this->logger->debug('Received event', ['event' => $event]); + return $event; + }); + + // Index events by ID + $eventsMap = []; + foreach ($events as $event) { + $eventsMap[$event->id] = $event; + } + + return $eventsMap; + } + /** * Fetch event by naddr * diff --git a/src/Service/RedisCacheService.php b/src/Service/RedisCacheService.php index 9f6d725..7e95805 100644 --- a/src/Service/RedisCacheService.php +++ b/src/Service/RedisCacheService.php @@ -7,6 +7,7 @@ use App\Entity\Event; use App\Enum\KindsEnum; use App\Util\NostrKeyUtil; use Doctrine\ORM\EntityManagerInterface; +use Exception; use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException; use Psr\Log\LoggerInterface; @@ -55,7 +56,7 @@ readonly class RedisCacheService $rawEvent = $this->fetchRawUserEvent($pubkey); return $this->parseUserMetadata($rawEvent, $pubkey); }); - } catch (InvalidArgumentException $e) { + } catch (Exception|InvalidArgumentException $e) { $this->logger->error('Error getting user data.', ['exception' => $e]); } // If content is still default, delete cache to retry next time @@ -73,6 +74,7 @@ readonly class RedisCacheService /** * Fetch raw user event from Nostr client, with error fallback. * @param string $pubkey Hex-encoded public key + * @throws Exception */ private function fetchRawUserEvent(string $pubkey): \stdClass { @@ -80,12 +82,8 @@ readonly class RedisCacheService return $this->nostrClient->getPubkeyMetadata($pubkey); } catch (\Exception $e) { $this->logger->error('Error getting user data.', ['exception' => $e]); - $rawEvent = new \stdClass(); - $rawEvent->content = json_encode([ - 'name' => substr($pubkey, 0, 8) . '…' . substr($pubkey, -4) - ]); - $rawEvent->tags = []; - return $rawEvent; + // Rethrow exception to be caught in getMetadata + throw $e; } } @@ -131,43 +129,6 @@ readonly class RedisCacheService return $contentData; } - /** - * Get metadata with raw event for debugging purposes. - * - * @param string $pubkey Hex-encoded public key - * @return array{metadata: \stdClass, rawEvent: \stdClass} - * @throws InvalidArgumentException - */ - public function getMetadataWithRawEvent(string $pubkey): array - { - if (!NostrKeyUtil::isHexPubkey($pubkey)) { - throw new \InvalidArgumentException('getMetadataWithRawEvent expects hex pubkey'); - } - $cacheKey = '0_with_raw_' . $pubkey; - try { - return $this->npubCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) { - $item->expiresAfter(3600); // 1 hour, adjust as needed - $rawEvent = $this->fetchRawUserEvent($pubkey); - $contentData = $this->parseUserMetadata($rawEvent, $pubkey); - return [ - 'metadata' => $contentData, - 'rawEvent' => $rawEvent - ]; - }); - } catch (InvalidArgumentException $e) { - $this->logger->error('Error getting user data with raw event.', ['exception' => $e]); - $content = new \stdClass(); - $content->name = substr($pubkey, 0, 8) . '…' . substr($pubkey, -4); - $rawEvent = new \stdClass(); - $rawEvent->content = json_encode($content); - $rawEvent->tags = []; - return [ - 'metadata' => $content, - 'rawEvent' => $rawEvent - ]; - } - } - /** * Fetch metadata for multiple pubkeys at once using Redis getItems. * Falls back to getMetadata for cache misses. diff --git a/src/Util/CommonMark/Converter.php b/src/Util/CommonMark/Converter.php index c35de76..8866964 100644 --- a/src/Util/CommonMark/Converter.php +++ b/src/Util/CommonMark/Converter.php @@ -24,6 +24,12 @@ use League\CommonMark\Extension\TableOfContents\TableOfContentsExtension; use League\CommonMark\MarkdownConverter; use League\CommonMark\Renderer\HtmlDecorator; use Twig\Environment as TwigEnvironment; +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; readonly class Converter { @@ -39,6 +45,9 @@ readonly class Converter */ public function convertToHTML(string $markdown): string { + // Preprocess nostr: links for batching + $markdown = $this->preprocessNostrLinks($markdown); + // Check if the article has more than three headings // Match all headings (from level 1 to 6) preg_match_all('/^#+\s.*$/m', $markdown, $matches); @@ -59,7 +68,7 @@ readonly class Converter ], 'embed' => [ 'adapter' => new OscaroteroEmbedAdapter(), // See the "Adapter" documentation below - 'allowed_domains' => ['youtube.com', 'twitter.com', 'github.com'], + 'allowed_domains' => ['youtube.com', 'x.com', 'github.com', 'fountain.fm'], 'fallback' => 'link' ], ]; @@ -69,8 +78,6 @@ readonly class Converter $environment->addExtension(new FootnoteExtension()); $environment->addExtension(new TableExtension()); $environment->addExtension(new StrikethroughExtension()); - // create a custom extension, that handles nostr mentions - $environment->addExtension(new NostrSchemeExtension($this->redisCacheService, $this->nostrClient, $this->twig, $this->nostrKeyUtil)); $environment->addExtension(new SmartPunctExtension()); $environment->addExtension(new EmbedExtension()); $environment->addRenderer(Embed::class, new HtmlDecorator(new EmbedRenderer(), 'div', ['class' => 'embedded-content'])); @@ -88,4 +95,140 @@ readonly class Converter return $converter->convert($content); } + private function preprocessNostrLinks(string $markdown): string + { + // Find all nostr: links + preg_match_all('/nostr:(?:npub1|nprofile1|note1|nevent1|naddr1)[^\\s<>()\\[\\]{}"\'`.,;:!?]*/', $markdown, $matches); + + if (empty($matches[0])) { + return $markdown; + } + + $links = array_unique($matches[0]); + $replacements = []; + + // Collect data for batching + $pubkeys = []; + $eventIds = []; + + foreach ($links as $link) { + $bechEncoded = substr($link, 6); // Remove "nostr:" + try { + $decoded = new Bech32($bechEncoded); + switch ($decoded->type) { + case 'npub': + /** @var NPub $object */ + $object = $decoded->data; + $hex = $this->nostrKeyUtil->npubToHex($bechEncoded); + $pubkeys[$hex] = $bechEncoded; + break; + case 'nprofile': + /** @var NProfile $object */ + $object = $decoded->data; + $pubkeys[$object->pubkey] = $bechEncoded; + break; + case 'note': + /** @var Note $object */ + $object = $decoded->data; + $eventIds[$object->data] = $bechEncoded; + break; + case 'nevent': + /** @var NEvent $object */ + $object = $decoded->data; + $eventIds[$object->id] = $bechEncoded; + break; + case 'naddr': + // For naddr, we might need to fetch the event, but for now, handle as simple link + break; + } + } catch (\Exception $e) { + // Invalid link, skip + continue; + } + } + + // Fetch metadata in batch (actually, getMetadata is cached, so just prepare) + $metadata = []; + foreach (array_keys($pubkeys) as $hex) { + try { + $metadata[$hex] = $this->redisCacheService->getMetadata($hex); + } catch (\Exception $e) { + $metadata[$hex] = null; + } + } + + // Fetch events in batch + $events = []; + if (!empty($eventIds)) { + try { + $events = $this->nostrClient->getEventsByIds(array_keys($eventIds)); + } catch (\Exception $e) { + // If batch fails, events remain empty + } + } + + // Now, render each link + foreach ($links as $link) { + $bechEncoded = substr($link, 6); + try { + $decoded = new Bech32($bechEncoded); + $html = $this->renderNostrLink($decoded, $bechEncoded, $metadata, $events); + $replacements[$link] = $html; + } catch (\Exception $e) { + // Keep original link if error + $replacements[$link] = $link; + } + } + + // Replace in markdown + return str_replace(array_keys($replacements), array_values($replacements), $markdown); + } + + private function renderNostrLink(Bech32 $decoded, string $bechEncoded, array $metadata, array $events): string + { + switch ($decoded->type) { + case 'npub': + $hex = $this->nostrKeyUtil->npubToHex($bechEncoded); + $profile = $metadata[$hex] ?? null; + if ($profile && isset($profile->name)) { + return '@' . htmlspecialchars($profile->name) . ''; + } else { + return '@' . substr($bechEncoded, 5, 8) . '…'; + } + case 'nprofile': + $object = $decoded->data; + return '@' . substr($bechEncoded, 9, 8) . '…'; + case 'note': + $object = $decoded->data; + $event = $events[$object->data] ?? null; + if ($event && $event->kind === 20) { + $pictureCardHtml = $this->twig->render('/event/_kind20_picture.html.twig', [ + 'event' => $event, + 'embed' => true + ]); + return $pictureCardHtml; + } else { + return '' . $bechEncoded . ''; + } + case 'nevent': + $object = $decoded->data; + $event = $events[$object->id] ?? null; + if ($event) { + $authorMetadata = $metadata[$event->pubkey] ?? null; + $eventCardHtml = $this->twig->render('components/event_card.html.twig', [ + 'event' => $event, + 'author' => $authorMetadata, + 'nevent' => $bechEncoded + ]); + return $eventCardHtml; + } else { + return '' . $bechEncoded . ''; + } + case 'naddr': + return '' . $bechEncoded . ''; + default: + return $bechEncoded; + } + } + } diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index 713c8a3..848e428 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -16,6 +16,10 @@ {% endif %} +
+        {{ article.topics|json_encode(constant('JSON_PRETTY_PRINT')) }}
+    
+
{% if canEdit %} Edit article diff --git a/templates/pages/author-about.html.twig b/templates/pages/author-about.html.twig deleted file mode 100644 index 20627cc..0000000 --- a/templates/pages/author-about.html.twig +++ /dev/null @@ -1,181 +0,0 @@ -{% extends 'layout.html.twig' %} - -{% block body %} - - {% include 'partial/_author-section.html.twig' with {author: author, npub: npub} %} - -
-
- Articles - Media - About -
- -
-
-

Profile Information

- - {% if author.about is defined %} -
-

About

-
- {{ author.about|markdown_to_html|mentionify|linkify }} -
-
- {% endif %} - - {% if author.banner is defined %} -
-

Banner

-
- Profile banner -
-
- {% endif %} - - {% if author.website is defined %} -
-

Website

- -
- {% endif %} - - {% if author.lud16 is defined %} -
-

Lightning Address{{ author.lud16 is iterable and author.lud16|length > 1 ? 'es' : '' }}

-
- {% if author.lud16 is iterable %} - {% for address in author.lud16 %} -
- {{ address }} -
- {% endfor %} - {% else %} - {{ author.lud16 }} - {% endif %} -
-
- {% endif %} - - {% if author.lud06 is defined %} -
-

LNURL{{ author.lud06 is iterable and author.lud06|length > 1 ? 's' : '' }}

-
- {% if author.lud06 is iterable %} - {% for lnurl in author.lud06 %} -
- {{ lnurl }} -
- {% endfor %} - {% else %} - {{ author.lud06 }} - {% endif %} -
-
- {% endif %} - -
-

Public Key (hex)

-
- {{ pubkey }} -
-
- -
-

Public Key (npub)

-
- {{ npub }} -
-
- - {# Display any additional fields that might be present #} - {% set standardFields = ['name', 'display_name', 'about', 'picture', 'banner', 'nip05', 'website', 'lud16', 'lud06', 'image'] %} - {% for key, value in author %} - {% if key not in standardFields and value is not empty %} -
-

{{ key|title }}

-
- {% if value starts with 'http://' or value starts with 'https://' %} - {{ value }} - {% else %} - {{ value }} - {% endif %} -
-
- {% endif %} - {% endfor %} -
- - {# Raw Event Debug Section #} -
-
- Raw Profile Event (Debug) -
-

Event ID:

-
{{ rawEvent.id ?? 'N/A' }}
- -

Created At:

-
{{ rawEvent.created_at ?? 'N/A' }} ({{ rawEvent.created_at is defined ? rawEvent.created_at|date('Y-m-d H:i:s') : 'N/A' }})
- -

Tags:

-
{{ rawEvent.tags is defined ? rawEvent.tags|json_encode(constant('JSON_PRETTY_PRINT')) : '[]' }}
- -

Content (JSON):

-
{{ rawEvent.content ?? '{}' }}
- -

Signature:

-
{{ rawEvent.sig ?? 'N/A' }}
- -

Full Event Object:

-
{{ rawEvent|json_encode(constant('JSON_PRETTY_PRINT')) }}
-
-
-
-
-
- - - -{% endblock %} diff --git a/templates/pages/author-media.html.twig b/templates/pages/author-media.html.twig index e1791c5..876b15c 100644 --- a/templates/pages/author-media.html.twig +++ b/templates/pages/author-media.html.twig @@ -8,9 +8,7 @@
Articles Media - {% if is_granted('ROLE_ADMIN') %} - About - {% endif %}
+
{% if pictureEvents|length > 0 %} diff --git a/templates/pages/author.html.twig b/templates/pages/author.html.twig index f7b3a14..44d094d 100644 --- a/templates/pages/author.html.twig +++ b/templates/pages/author.html.twig @@ -8,9 +8,6 @@
Articles Media - {% if is_granted('ROLE_ADMIN') %} - About - {% endif %}