Browse Source

Better metadata handling

imwald
Nuša Pukšič 3 months ago
parent
commit
6b2af58608
  1. 27
      src/Controller/DefaultController.php
  2. 212
      src/Service/RedisCacheService.php
  3. 23
      src/Twig/Components/Molecules/UserFromNpub.php
  4. 1
      src/Twig/Components/Organisms/CardList.php
  5. 4
      templates/components/Molecules/Card.html.twig
  6. 2
      templates/components/Organisms/CardList.html.twig
  7. 2
      templates/pages/latest-articles.html.twig

27
src/Controller/DefaultController.php

@ -9,6 +9,7 @@ use App\Entity\Event;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Service\RedisCacheService; use App\Service\RedisCacheService;
use App\Util\CommonMark\Converter; use App\Util\CommonMark\Converter;
use App\Util\NostrKeyUtil;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Elastica\Collapse; use Elastica\Collapse;
use Elastica\Query; use Elastica\Query;
@ -52,7 +53,9 @@ class DefaultController extends AbstractController
* @throws Exception * @throws Exception
*/ */
#[Route('/latest-articles', name: 'latest_articles')] #[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 set_time_limit(300); // 5 minutes
ini_set('max_execution_time', '300'); ini_set('max_execution_time', '300');
@ -80,7 +83,6 @@ class DefaultController extends AbstractController
$query->setSize(30); $query->setSize(30);
$query->setSort(['createdAt' => ['order' => 'desc']]); $query->setSort(['createdAt' => ['order' => 'desc']]);
// Use collapse to deduplicate by slug field // Use collapse to deduplicate by slug field
$collapse = new Collapse(); $collapse = new Collapse();
$collapse->setFieldname('slug'); $collapse->setFieldname('slug');
@ -93,9 +95,26 @@ class DefaultController extends AbstractController
$articlesCache->save($cacheItem); $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', [ return $this->render('pages/latest-articles.html.twig', [
'articles' => $cacheItem->get(), 'articles' => $articles,
'newsBots' => array_slice($excludedPubkeys, 0, 4) 'newsBots' => array_slice($excludedPubkeys, 0, 4),
'authorsMetadata' => $authorsMetadata
]); ]);
} }

212
src/Service/RedisCacheService.php

@ -1,187 +1,154 @@
<?php <?php
declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Entity\Event; use App\Entity\Event;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Util\NostrKeyUtil;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key; use swentel\nostr\Key\Key;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface; use Symfony\Contracts\Cache\ItemInterface;
readonly class RedisCacheService readonly class RedisCacheService
{ {
public function __construct( public function __construct(
private NostrClient $nostrClient, private NostrClient $nostrClient,
private CacheInterface $redisCache, private CacheItemPoolInterface $redisCache,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private LoggerInterface $logger private LoggerInterface $logger
) ) {}
/**
* Generate the cache key for user metadata (hex pubkey only).
*/
private function getUserCacheKey(string $pubkey): string
{ {
return '0_' . $pubkey;
} }
/** /**
* @param string $npub * @param string $pubkey Hex-encoded public key
* @return \stdClass * @return \stdClass
* @throws InvalidArgumentException
*/ */
public function getMetadata(string $npub): \stdClass public function getMetadata(string $pubkey): \stdClass
{ {
$cacheKey = '0_' . $npub; if (!NostrKeyUtil::isHexPubkey($pubkey)) {
throw new \InvalidArgumentException('getMetadata expects hex pubkey');
}
$cacheKey = $this->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 { 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 $item->expiresAfter(3600); // 1 hour, adjust as needed
$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 { try {
$rawEvent = $this->nostrClient->getNpubMetadata($npub); $this->redisCache->deleteItem($cacheKey);
} catch (\Exception $e) {
$this->logger->error('Error deleting user cache item.', ['exception' => $e]);
}
}
return $content;
}
/**
* 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) { } catch (\Exception $e) {
$this->logger->error('Error getting user data.', ['exception' => $e]); $this->logger->error('Error getting user data.', ['exception' => $e]);
$rawEvent = new \stdClass(); $rawEvent = new \stdClass();
$rawEvent->content = json_encode([ $rawEvent->content = json_encode([
'name' => substr($npub, 0, 8) . '…' . substr($npub, -4) 'name' => substr($pubkey, 0, 8) . '…' . substr($pubkey, -4)
]); ]);
$rawEvent->tags = []; $rawEvent->tags = [];
return $rawEvent;
}
} }
// Parse content as JSON /**
* Parse user metadata from a raw event object.
*/
private function parseUserMetadata(\stdClass $rawEvent, string $pubkey): \stdClass
{
$contentData = json_decode($rawEvent->content ?? '{}'); $contentData = json_decode($rawEvent->content ?? '{}');
if (!$contentData) { if (!$contentData) {
$contentData = new \stdClass(); $contentData = new \stdClass();
} }
// Fields that should be collected as arrays when multiple values exist
$arrayFields = ['nip05', 'lud16', 'lud06']; $arrayFields = ['nip05', 'lud16', 'lud06'];
$arrayCollectors = []; $arrayCollectors = [];
// Parse tags and merge/override content data
// Common metadata tags: name, about, picture, banner, nip05, lud16, website, etc.
$tags = $rawEvent->tags ?? []; $tags = $rawEvent->tags ?? [];
foreach ($tags as $tag) { foreach ($tags as $tag) {
if (is_array($tag) && count($tag) >= 2) { if (is_array($tag) && count($tag) >= 2) {
$tagName = $tag[0]; $tagName = $tag[0];
if (in_array($tagName, $arrayFields, true)) {
// Check if this field should be collected as an array
if (in_array($tagName, $arrayFields)) {
if (!isset($arrayCollectors[$tagName])) { if (!isset($arrayCollectors[$tagName])) {
$arrayCollectors[$tagName] = []; $arrayCollectors[$tagName] = [];
} }
// Collect all values from position 1 onwards (tag can have multiple values)
for ($i = 1; $i < count($tag); $i++) { for ($i = 1; $i < count($tag); $i++) {
$arrayCollectors[$tagName][] = $tag[$i]; $arrayCollectors[$tagName][] = $tag[$i];
} }
} else { } elseif (!isset($contentData->$tagName) && isset($tag[1])) {
// 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]; $contentData->$tagName = $tag[1];
} }
} }
} }
}
// Merge array collectors into content data
foreach ($arrayCollectors as $fieldName => $values) { foreach ($arrayCollectors as $fieldName => $values) {
// Remove duplicates $contentData->$fieldName = array_unique($values);
$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) { foreach ($arrayFields as $fieldName) {
if (isset($contentData->$fieldName) && !is_array($contentData->$fieldName)) { if (isset($contentData->$fieldName) && !is_array($contentData->$fieldName)) {
$contentData->$fieldName = [$contentData->$fieldName]; $contentData->$fieldName = [$contentData->$fieldName];
} }
} }
$this->logger->info('Metadata (with tags):', [ $this->logger->info('Metadata (with tags):', [
'meta' => json_encode($contentData), 'meta' => json_encode($contentData),
'tags' => json_encode($tags) 'tags' => json_encode($tags)
]); ]);
return $contentData; 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;
}
} }
/** /**
* Get metadata with raw event for debugging purposes. * Get metadata with raw event for debugging purposes.
* *
* @param string $npub * @param string $pubkey Hex-encoded public key
* @return array{metadata: \stdClass, rawEvent: \stdClass} * @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 { 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 $item->expiresAfter(3600); // 1 hour, adjust as needed
try { $rawEvent = $this->fetchRawUserEvent($pubkey);
$rawEvent = $this->nostrClient->getNpubMetadata($npub); $contentData = $this->parseUserMetadata($rawEvent, $pubkey);
} 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];
}
}
return [ return [
'metadata' => $contentData, 'metadata' => $contentData,
'rawEvent' => $rawEvent 'rawEvent' => $rawEvent
@ -190,7 +157,7 @@ readonly class RedisCacheService
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
$this->logger->error('Error getting user data with raw event.', ['exception' => $e]); $this->logger->error('Error getting user data with raw event.', ['exception' => $e]);
$content = new \stdClass(); $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 = new \stdClass();
$rawEvent->content = json_encode($content); $rawEvent->content = json_encode($content);
$rawEvent->tags = []; $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<string, \stdClass> 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) public function getRelays($npub)
{ {
$cacheKey = '10002_' . $npub; $cacheKey = '10002_' . $npub;
@ -471,4 +470,5 @@ readonly class RedisCacheService
$this->logger->error('Error setting user metadata.', ['exception' => $e]); $this->logger->error('Error setting user metadata.', ['exception' => $e]);
} }
} }
} }

23
src/Twig/Components/Molecules/UserFromNpub.php

@ -3,7 +3,7 @@
namespace App\Twig\Components\Molecules; namespace App\Twig\Components\Molecules;
use App\Service\RedisCacheService; use App\Service\RedisCacheService;
use swentel\nostr\Key\Key; use App\Util\NostrKeyUtil;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[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 $this->user = $user;
if (!str_starts_with($ident, 'npub')) { if (NostrKeyUtil::isHexPubkey($ident)) {
$keys = new Key();
$this->pubkey = $ident; $this->pubkey = $ident;
$this->npub = $keys->convertPublicKeyToBech32($ident); $this->npub = NostrKeyUtil::hexToNpub($ident);
} else { } elseif (NostrKeyUtil::isNpub($ident)) {
$this->npub = $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);
} }
} }

1
src/Twig/Components/Organisms/CardList.php

@ -11,4 +11,5 @@ final class CardList
public array $list; public array $list;
public ?string $mag = null; // magazine slug passed from parent (optional) public ?string $mag = null; // magazine slug passed from parent (optional)
public ?Event $category = null; // category index passed from parent (optional) public ?Event $category = null; // category index passed from parent (optional)
public array $authorsMetadata = [];
} }

4
templates/components/Molecules/Card.html.twig

@ -2,7 +2,9 @@
<div class="card"> <div class="card">
<div class="metadata"> <div class="metadata">
{% if not is_author_profile %} {% if not is_author_profile %}
<p>by <twig:Molecules:UserFromNpub ident="{{ article.pubkey }}" /></p> {% set pubkey = article.pubkey %}
{% set user = authors_metadata[pubkey] ?? null %}
<p>by <twig:Molecules:UserFromNpub :user="user" :ident="pubkey" /></p>
{% endif %} {% endif %}
<small>{{ article.createdAt|date('F j Y') }}</small> <small>{{ article.createdAt|date('F j Y') }}</small>
</div> </div>

2
templates/components/Organisms/CardList.html.twig

@ -2,7 +2,7 @@
{% set is_author_profile = is_author_profile|default(false) %} {% set is_author_profile = is_author_profile|default(false) %}
{% for item in list %} {% for item in list %}
{% if item.slug is not empty and item.title is not empty %} {% if item.slug is not empty and item.title is not empty %}
<twig:Molecules:Card :article="item" :is_author_profile="is_author_profile" :category="category" :mag="mag"></twig:Molecules:Card> <twig:Molecules:Card :article="item" :is_author_profile="is_author_profile" :category="category" :mag="mag" :authors_metadata="authorsMetadata"></twig:Molecules:Card>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>

2
templates/pages/latest-articles.html.twig

@ -14,7 +14,7 @@
No published articles found. No published articles found.
</div> </div>
{% else %} {% else %}
<twig:Organisms:CardList :list="articles" class="article-list" /> <twig:Organisms:CardList :list="articles" :authorsMetadata="authorsMetadata" class="article-list" />
{% endif %} {% endif %}
</section> </section>
{% endblock %} {% endblock %}

Loading…
Cancel
Save