From 6b2af5860881619db04d16c56f4716bba71b3001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Fri, 10 Oct 2025 12:34:01 +0200 Subject: [PATCH] Better metadata handling --- src/Controller/DefaultController.php | 27 +- src/Service/RedisCacheService.php | 284 +++++++++--------- .../Components/Molecules/UserFromNpub.php | 23 +- src/Twig/Components/Organisms/CardList.php | 1 + templates/components/Molecules/Card.html.twig | 4 +- .../components/Organisms/CardList.html.twig | 2 +- templates/pages/latest-articles.html.twig | 2 +- 7 files changed, 186 insertions(+), 157 deletions(-) diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index d77326b..b4c9a48 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -9,6 +9,7 @@ use App\Entity\Event; use App\Enum\KindsEnum; use App\Service\RedisCacheService; use App\Util\CommonMark\Converter; +use App\Util\NostrKeyUtil; use Doctrine\ORM\EntityManagerInterface; use Elastica\Collapse; use Elastica\Query; @@ -52,7 +53,9 @@ class DefaultController extends AbstractController * @throws Exception */ #[Route('/latest-articles', name: 'latest_articles')] - public function latestArticles(FinderInterface $finder, CacheItemPoolInterface $articlesCache): Response + public function latestArticles(FinderInterface $finder, + RedisCacheService $redisCacheService, + CacheItemPoolInterface $articlesCache): Response { set_time_limit(300); // 5 minutes ini_set('max_execution_time', '300'); @@ -80,7 +83,6 @@ class DefaultController extends AbstractController $query->setSize(30); $query->setSort(['createdAt' => ['order' => 'desc']]); - // Use collapse to deduplicate by slug field $collapse = new Collapse(); $collapse->setFieldname('slug'); @@ -93,9 +95,26 @@ class DefaultController extends AbstractController $articlesCache->save($cacheItem); } + $articles = $cacheItem->get(); + + // Collect all unique author pubkeys from articles + $authorPubkeys = []; + foreach ($articles as $article) { + if (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) { + $authorPubkeys[] = $article->pubkey; + } elseif (isset($article->npub) && NostrKeyUtil::isNpub($article->npub)) { + $authorPubkeys[] = NostrKeyUtil::npubToHex($article->npub); + } + } + $authorPubkeys = array_unique($authorPubkeys); + + // Fetch all author metadata in one batch using pubkeys + $authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys); + return $this->render('pages/latest-articles.html.twig', [ - 'articles' => $cacheItem->get(), - 'newsBots' => array_slice($excludedPubkeys, 0, 4) + 'articles' => $articles, + 'newsBots' => array_slice($excludedPubkeys, 0, 4), + 'authorsMetadata' => $authorsMetadata ]); } diff --git a/src/Service/RedisCacheService.php b/src/Service/RedisCacheService.php index 0dd9151..6717051 100644 --- a/src/Service/RedisCacheService.php +++ b/src/Service/RedisCacheService.php @@ -1,187 +1,154 @@ getUserCacheKey($pubkey); + // Default content if fetching/parsing fails + $content = new \stdClass(); + // Pubkey to npub + $npub = NostrKeyUtil::hexToNpub($pubkey); + $defaultName = '@' . substr($npub, 5, 4) . '…' . substr($npub, -4); + $content->name = $defaultName; + try { - return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($npub) { + $content = $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) { $item->expiresAfter(3600); // 1 hour, adjust as needed - try { - $rawEvent = $this->nostrClient->getNpubMetadata($npub); - } catch (\Exception $e) { - $this->logger->error('Error getting user data.', ['exception' => $e]); - $rawEvent = new \stdClass(); - $rawEvent->content = json_encode([ - 'name' => substr($npub, 0, 8) . '…' . substr($npub, -4) - ]); - $rawEvent->tags = []; - } + $rawEvent = $this->fetchRawUserEvent($pubkey); + return $this->parseUserMetadata($rawEvent, $pubkey); + }); + } catch (InvalidArgumentException $e) { + $this->logger->error('Error getting user data.', ['exception' => $e]); + } + // If content is still default, delete cache to retry next time + if (isset($content->name) && $content->name === $defaultName + && $this->redisCache->hasItem($cacheKey)) { + try { + $this->redisCache->deleteItem($cacheKey); + } catch (\Exception $e) { + $this->logger->error('Error deleting user cache item.', ['exception' => $e]); + } + } + return $content; + } - // Parse content as JSON - $contentData = json_decode($rawEvent->content ?? '{}'); - if (!$contentData) { - $contentData = new \stdClass(); - } + /** + * Fetch raw user event from Nostr client, with error fallback. + * @param string $pubkey Hex-encoded public key + */ + private function fetchRawUserEvent(string $pubkey): \stdClass + { + try { + return $this->nostrClient->getNpubMetadata(NostrKeyUtil::hexToNpub($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; + } + } - // Fields that should be collected as arrays when multiple values exist - $arrayFields = ['nip05', 'lud16', 'lud06']; - $arrayCollectors = []; - - // Parse tags and merge/override content data - // Common metadata tags: name, about, picture, banner, nip05, lud16, website, etc. - $tags = $rawEvent->tags ?? []; - foreach ($tags as $tag) { - if (is_array($tag) && count($tag) >= 2) { - $tagName = $tag[0]; - - // Check if this field should be collected as an array - if (in_array($tagName, $arrayFields)) { - if (!isset($arrayCollectors[$tagName])) { - $arrayCollectors[$tagName] = []; - } - // Collect all values from position 1 onwards (tag can have multiple values) - for ($i = 1; $i < count($tag); $i++) { - $arrayCollectors[$tagName][] = $tag[$i]; - } - } else { - // Override content field with tag value (first occurrence wins for non-array fields) - // For non-array fields, only use the first value (tag[1]) - if (!isset($contentData->$tagName) && isset($tag[1])) { - $contentData->$tagName = $tag[1]; - } - } + /** + * Parse user metadata from a raw event object. + */ + private function parseUserMetadata(\stdClass $rawEvent, string $pubkey): \stdClass + { + $contentData = json_decode($rawEvent->content ?? '{}'); + if (!$contentData) { + $contentData = new \stdClass(); + } + $arrayFields = ['nip05', 'lud16', 'lud06']; + $arrayCollectors = []; + $tags = $rawEvent->tags ?? []; + foreach ($tags as $tag) { + if (is_array($tag) && count($tag) >= 2) { + $tagName = $tag[0]; + if (in_array($tagName, $arrayFields, true)) { + if (!isset($arrayCollectors[$tagName])) { + $arrayCollectors[$tagName] = []; } - } - - // Merge array collectors into content data - foreach ($arrayCollectors as $fieldName => $values) { - // Remove duplicates - $values = array_unique($values); - $contentData->$fieldName = $values; - } - - // If content had a single value for an array field but no tags, convert to array - foreach ($arrayFields as $fieldName) { - if (isset($contentData->$fieldName) && !is_array($contentData->$fieldName)) { - $contentData->$fieldName = [$contentData->$fieldName]; + for ($i = 1; $i < count($tag); $i++) { + $arrayCollectors[$tagName][] = $tag[$i]; } + } elseif (!isset($contentData->$tagName) && isset($tag[1])) { + $contentData->$tagName = $tag[1]; } - - $this->logger->info('Metadata (with tags):', [ - 'meta' => json_encode($contentData), - 'tags' => json_encode($tags) - ]); - - return $contentData; - }); - } catch (InvalidArgumentException $e) { - $this->logger->error('Error getting user data.', ['exception' => $e]); - $content = new \stdClass(); - $content->name = substr($npub, 0, 8) . '…' . substr($npub, -4); - return $content; + } + } + foreach ($arrayCollectors as $fieldName => $values) { + $contentData->$fieldName = array_unique($values); } + foreach ($arrayFields as $fieldName) { + if (isset($contentData->$fieldName) && !is_array($contentData->$fieldName)) { + $contentData->$fieldName = [$contentData->$fieldName]; + } + } + $this->logger->info('Metadata (with tags):', [ + 'meta' => json_encode($contentData), + 'tags' => json_encode($tags) + ]); + return $contentData; } /** * Get metadata with raw event for debugging purposes. * - * @param string $npub + * @param string $pubkey Hex-encoded public key * @return array{metadata: \stdClass, rawEvent: \stdClass} + * @throws InvalidArgumentException */ - public function getMetadataWithRawEvent(string $npub): array + public function getMetadataWithRawEvent(string $pubkey): array { - $cacheKey = '0_with_raw_' . $npub; + if (!NostrKeyUtil::isHexPubkey($pubkey)) { + throw new \InvalidArgumentException('getMetadataWithRawEvent expects hex pubkey'); + } + $cacheKey = '0_with_raw_' . $pubkey; try { - return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($npub) { + return $this->redisCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) { $item->expiresAfter(3600); // 1 hour, adjust as needed - try { - $rawEvent = $this->nostrClient->getNpubMetadata($npub); - } catch (\Exception $e) { - $this->logger->error('Error getting user data.', ['exception' => $e]); - $rawEvent = new \stdClass(); - $rawEvent->content = json_encode([ - 'name' => substr($npub, 0, 8) . '…' . substr($npub, -4) - ]); - $rawEvent->tags = []; - } - - // Parse content as JSON - $contentData = json_decode($rawEvent->content ?? '{}'); - if (!$contentData) { - $contentData = new \stdClass(); - } - - // Fields that should be collected as arrays when multiple values exist - $arrayFields = ['nip05', 'lud16', 'lud06']; - $arrayCollectors = []; - - // Parse tags and merge/override content data - $tags = $rawEvent->tags ?? []; - foreach ($tags as $tag) { - if (is_array($tag) && count($tag) >= 2) { - $tagName = $tag[0]; - - // Check if this field should be collected as an array - if (in_array($tagName, $arrayFields)) { - if (!isset($arrayCollectors[$tagName])) { - $arrayCollectors[$tagName] = []; - } - // Collect all values from position 1 onwards (tag can have multiple values) - for ($i = 1; $i < count($tag); $i++) { - $arrayCollectors[$tagName][] = $tag[$i]; - } - } else { - // Override content field with tag value (first occurrence wins for non-array fields) - // For non-array fields, only use the first value (tag[1]) - if (!isset($contentData->$tagName) && isset($tag[1])) { - $contentData->$tagName = $tag[1]; - } - } - } - } - - // Merge array collectors into content data - foreach ($arrayCollectors as $fieldName => $values) { - // Remove duplicates - $values = array_unique($values); - $contentData->$fieldName = $values; - } - - // If content had a single value for an array field but no tags, convert to array - foreach ($arrayFields as $fieldName) { - if (isset($contentData->$fieldName) && !is_array($contentData->$fieldName)) { - $contentData->$fieldName = [$contentData->$fieldName]; - } - } - + $rawEvent = $this->fetchRawUserEvent($pubkey); + $contentData = $this->parseUserMetadata($rawEvent, $pubkey); return [ 'metadata' => $contentData, 'rawEvent' => $rawEvent @@ -190,7 +157,7 @@ readonly class RedisCacheService } catch (InvalidArgumentException $e) { $this->logger->error('Error getting user data with raw event.', ['exception' => $e]); $content = new \stdClass(); - $content->name = substr($npub, 0, 8) . '…' . substr($npub, -4); + $content->name = substr($pubkey, 0, 8) . '…' . substr($pubkey, -4); $rawEvent = new \stdClass(); $rawEvent->content = json_encode($content); $rawEvent->tags = []; @@ -201,6 +168,38 @@ readonly class RedisCacheService } } + /** + * Fetch metadata for multiple pubkeys at once using Redis getItems. + * Falls back to getMetadata for cache misses. + * + * @param string[] $pubkeys Array of hex pubkeys + * @return array Map of pubkey => metadata + * @throws InvalidArgumentException + */ + public function getMultipleMetadata(array $pubkeys): array + { + foreach ($pubkeys as $pubkey) { + if (!NostrKeyUtil::isHexPubkey($pubkey)) { + throw new \InvalidArgumentException('getMultipleMetadata expects all hex pubkeys'); + } + } + $result = []; + $cacheKeys = array_map(fn($pubkey) => $this->getUserCacheKey($pubkey), $pubkeys); + $pubkeyMap = array_combine($cacheKeys, $pubkeys); + $items = $this->redisCache->getItems($cacheKeys); + foreach ($items as $cacheKey => $item) { + $pubkey = $pubkeyMap[$cacheKey]; + if ($item->isHit()) { + $result[$pubkey] = $item->get(); + } + } + $missedPubkeys = array_diff($pubkeys, array_keys($result)); + foreach ($missedPubkeys as $pubkey) { + $result[$pubkey] = $this->getMetadata($pubkey); + } + return $result; + } + public function getRelays($npub) { $cacheKey = '10002_' . $npub; @@ -471,4 +470,5 @@ readonly class RedisCacheService $this->logger->error('Error setting user metadata.', ['exception' => $e]); } } + } diff --git a/src/Twig/Components/Molecules/UserFromNpub.php b/src/Twig/Components/Molecules/UserFromNpub.php index de04df2..f5b5f26 100644 --- a/src/Twig/Components/Molecules/UserFromNpub.php +++ b/src/Twig/Components/Molecules/UserFromNpub.php @@ -3,7 +3,7 @@ namespace App\Twig\Components\Molecules; use App\Service\RedisCacheService; -use swentel\nostr\Key\Key; +use App\Util\NostrKeyUtil; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; #[AsTwigComponent] @@ -17,16 +17,23 @@ final class UserFromNpub { } - public function mount(string $ident): void + /** + * Accepts either npub or pubkey as ident. Always converts to pubkey for lookups. + */ + public function mount(string $ident, $user = null): void { - // if npub doesn't start with 'npub' then assume it's a hex pubkey - if (!str_starts_with($ident, 'npub')) { - $keys = new Key(); + $this->user = $user; + if (NostrKeyUtil::isHexPubkey($ident)) { $this->pubkey = $ident; - $this->npub = $keys->convertPublicKeyToBech32($ident); - } else { + $this->npub = NostrKeyUtil::hexToNpub($ident); + } elseif (NostrKeyUtil::isNpub($ident)) { $this->npub = $ident; + $this->pubkey = NostrKeyUtil::npubToHex($ident); + } else { + throw new \InvalidArgumentException('UserFromNpub expects npub or hex pubkey'); + } + if ($this->user === null) { + $this->user = $this->redisCacheService->getMetadata($this->pubkey); } - $this->user = $this->redisCacheService->getMetadata($this->npub); } } diff --git a/src/Twig/Components/Organisms/CardList.php b/src/Twig/Components/Organisms/CardList.php index a415d78..728605a 100644 --- a/src/Twig/Components/Organisms/CardList.php +++ b/src/Twig/Components/Organisms/CardList.php @@ -11,4 +11,5 @@ final class CardList public array $list; public ?string $mag = null; // magazine slug passed from parent (optional) public ?Event $category = null; // category index passed from parent (optional) + public array $authorsMetadata = []; } diff --git a/templates/components/Molecules/Card.html.twig b/templates/components/Molecules/Card.html.twig index 61f5654..9e38d29 100644 --- a/templates/components/Molecules/Card.html.twig +++ b/templates/components/Molecules/Card.html.twig @@ -2,7 +2,9 @@
diff --git a/templates/components/Organisms/CardList.html.twig b/templates/components/Organisms/CardList.html.twig index a87490f..502f079 100644 --- a/templates/components/Organisms/CardList.html.twig +++ b/templates/components/Organisms/CardList.html.twig @@ -2,7 +2,7 @@ {% set is_author_profile = is_author_profile|default(false) %} {% for item in list %} {% if item.slug is not empty and item.title is not empty %} - + {% endif %} {% endfor %}
diff --git a/templates/pages/latest-articles.html.twig b/templates/pages/latest-articles.html.twig index da1174e..4ba2f35 100644 --- a/templates/pages/latest-articles.html.twig +++ b/templates/pages/latest-articles.html.twig @@ -14,7 +14,7 @@ No published articles found. {% else %} - + {% endif %} {% endblock %}