You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
443 lines
16 KiB
443 lines
16 KiB
<?php |
|
declare(strict_types=1); |
|
|
|
namespace App\Service; |
|
|
|
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; |
|
use swentel\nostr\Key\Key; |
|
use Symfony\Component\HttpFoundation\Response; |
|
use Symfony\Contracts\Cache\ItemInterface; |
|
|
|
readonly class RedisCacheService |
|
{ |
|
public function __construct( |
|
private NostrClient $nostrClient, |
|
private CacheItemPoolInterface $npubCache, |
|
private EntityManagerInterface $entityManager, |
|
private LoggerInterface $logger |
|
) {} |
|
|
|
/** |
|
* Generate the cache key for user metadata (hex pubkey only). |
|
*/ |
|
private function getUserCacheKey(string $pubkey): string |
|
{ |
|
return '0_' . $pubkey; |
|
} |
|
|
|
/** |
|
* @param string $pubkey Hex-encoded public key |
|
* @return \stdClass |
|
* @throws InvalidArgumentException |
|
*/ |
|
public function getMetadata(string $pubkey): \stdClass |
|
{ |
|
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 { |
|
$content = $this->npubCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) { |
|
$item->expiresAfter(3600); // 1 hour, adjust as needed |
|
$rawEvent = $this->fetchRawUserEvent($pubkey); |
|
return $this->parseUserMetadata($rawEvent, $pubkey); |
|
}); |
|
} catch (Exception|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->npubCache->hasItem($cacheKey)) { |
|
try { |
|
$this->npubCache->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 |
|
* @throws Exception |
|
*/ |
|
private function fetchRawUserEvent(string $pubkey): \stdClass |
|
{ |
|
try { |
|
return $this->nostrClient->getPubkeyMetadata($pubkey); |
|
} catch (\Exception $e) { |
|
$this->logger->error('Error getting user data.', ['exception' => $e]); |
|
// Rethrow exception to be caught in getMetadata |
|
throw $e; |
|
} |
|
} |
|
|
|
/** |
|
* 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] = []; |
|
} |
|
for ($i = 1; $i < count($tag); $i++) { |
|
$arrayCollectors[$tagName][] = $tag[$i]; |
|
} |
|
} elseif (!isset($contentData->$tagName) && isset($tag[1])) { |
|
$contentData->$tagName = $tag[1]; |
|
} |
|
} |
|
} |
|
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; |
|
} |
|
|
|
/** |
|
* 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->npubCache->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; |
|
|
|
try { |
|
return $this->npubCache->get($cacheKey, function (ItemInterface $item) use ($npub) { |
|
$item->expiresAfter(3600); // 1 hour, adjust as needed |
|
try { |
|
$relays = $this->nostrClient->getNpubRelays($npub); |
|
} catch (\Exception $e) { |
|
$this->logger->error('Error getting user relays.', ['exception' => $e]); |
|
} |
|
return $relays ?? []; |
|
}); |
|
} catch (InvalidArgumentException $e) { |
|
$this->logger->error('Error getting user relays.', ['exception' => $e]); |
|
return []; |
|
} |
|
} |
|
|
|
/** |
|
* Get a magazine index object by key. |
|
* @param string $slug |
|
* @return object|null |
|
* @throws InvalidArgumentException |
|
*/ |
|
public function getMagazineIndex(string $slug): ?object |
|
{ |
|
// redis cache lookup of magazine index by slug |
|
$key = 'magazine-index-' . $slug; |
|
return $this->npubCache->get($key, function (ItemInterface $item) use ($slug) { |
|
$item->expiresAfter(3600); // 1 hour |
|
|
|
$nzines = $this->entityManager->getRepository(Event::class)->findBy(['kind' => KindsEnum::PUBLICATION_INDEX]); |
|
// filter, only keep type === magazine and slug === $mag |
|
$nzines = array_filter($nzines, function ($index) use ($slug) { |
|
// look for slug |
|
return $index->getSlug() === $slug; |
|
}); |
|
|
|
if (count($nzines) === 0) { |
|
return new Response('Magazine not found', 404); |
|
} |
|
// sort by createdAt, keep newest |
|
usort($nzines, function ($a, $b) { |
|
return $b->getCreatedAt() <=> $a->getCreatedAt(); |
|
}); |
|
$nzine = array_shift($nzines); |
|
|
|
$this->logger->info('Magazine lookup', ['mag' => $slug, 'found' => json_encode($nzine)]); |
|
|
|
return $nzine; |
|
}); |
|
} |
|
|
|
/** |
|
* Update a magazine index by inserting a new article tag at the top. |
|
* @param string $key |
|
* @param array $articleTag The tag array, e.g. ['a', 'article:slug', ...] |
|
* @return bool |
|
*/ |
|
public function addArticleToIndex(string $key, array $articleTag): bool |
|
{ |
|
$index = $this->getMagazineIndex($key); |
|
if (!$index || !isset($index->tags) || !is_array($index->tags)) { |
|
$this->logger->error('Invalid index object or missing tags array.'); |
|
return false; |
|
} |
|
// Insert the new article tag at the top |
|
array_unshift($index->tags, $articleTag); |
|
try { |
|
$item = $this->npubCache->getItem($key); |
|
$item->set($index); |
|
$this->npubCache->save($item); |
|
return true; |
|
} catch (\Exception $e) { |
|
$this->logger->error('Error updating magazine index.', ['exception' => $e]); |
|
return false; |
|
} |
|
} |
|
|
|
/** |
|
* Get media events (pictures and videos) for a given npub with caching |
|
* |
|
* @param string $npub The author's npub |
|
* @param int $limit Maximum number of events to fetch |
|
* @return array Array of media events |
|
*/ |
|
public function getMediaEvents(string $npub, int $limit = 30): array |
|
{ |
|
$cacheKey = 'media_' . $npub . '_' . $limit; |
|
try { |
|
return $this->npubCache->get($cacheKey, function (ItemInterface $item) use ($npub, $limit) { |
|
$item->expiresAfter(600); // 10 minutes cache for media events |
|
|
|
try { |
|
// Use the optimized single-request method |
|
$mediaEvents = $this->nostrClient->getAllMediaEventsForPubkey($npub, $limit); |
|
|
|
// Deduplicate by event ID |
|
$uniqueEvents = []; |
|
foreach ($mediaEvents as $event) { |
|
if (!isset($uniqueEvents[$event->id])) { |
|
$uniqueEvents[$event->id] = $event; |
|
} |
|
} |
|
|
|
// Convert back to indexed array and sort by date (newest first) |
|
$mediaEvents = array_values($uniqueEvents); |
|
usort($mediaEvents, function ($a, $b) { |
|
return $b->created_at <=> $a->created_at; |
|
}); |
|
|
|
return $mediaEvents; |
|
} catch (\Exception $e) { |
|
$this->logger->error('Error getting media events.', ['exception' => $e, 'npub' => $npub]); |
|
return []; |
|
} |
|
}); |
|
} catch (InvalidArgumentException $e) { |
|
$this->logger->error('Cache error getting media events.', ['exception' => $e]); |
|
return []; |
|
} |
|
} |
|
|
|
/** |
|
* Get all media events for pagination (fetches large batch, caches, returns paginated) |
|
* |
|
* @param string $pubkey The author's pubkey |
|
* @param int $page Page number (1-based) |
|
* @param int $pageSize Number of items per page |
|
* @return array ['events' => array, 'hasMore' => bool, 'total' => int] |
|
*/ |
|
public function getMediaEventsPaginated(string $pubkey, int $page = 1, int $pageSize = 60): array |
|
{ |
|
// Cache key for all media events (not page-specific) |
|
$cacheKey = 'media_all_' . $pubkey; |
|
|
|
try { |
|
// Fetch and cache all media events |
|
$allMediaEvents = $this->npubCache->get($cacheKey, function (ItemInterface $item) use ($pubkey) { |
|
$item->expiresAfter(600); // 10 minutes cache |
|
|
|
try { |
|
// Fetch a large batch to account for deduplication |
|
// Nostr relays are unstable, so we fetch more than we need |
|
$mediaEvents = $this->nostrClient->getAllMediaEventsForPubkey($pubkey, 200); |
|
|
|
// Deduplicate by event ID |
|
$uniqueEvents = []; |
|
foreach ($mediaEvents as $event) { |
|
if (!isset($uniqueEvents[$event->id])) { |
|
$uniqueEvents[$event->id] = $event; |
|
} |
|
} |
|
|
|
// Convert back to indexed array and sort by date (newest first) |
|
$mediaEvents = array_values($uniqueEvents); |
|
usort($mediaEvents, function ($a, $b) { |
|
return $b->created_at <=> $a->created_at; |
|
}); |
|
|
|
return $mediaEvents; |
|
} catch (\Exception $e) { |
|
$this->logger->error('Error getting media events.', ['exception' => $e, 'pubkey' => $pubkey]); |
|
return []; |
|
} |
|
}); |
|
|
|
// Perform pagination on cached results |
|
$total = count($allMediaEvents); |
|
$offset = ($page - 1) * $pageSize; |
|
|
|
// Get the page slice |
|
$pageEvents = array_slice($allMediaEvents, $offset, $pageSize); |
|
|
|
// Check if there are more pages |
|
$hasMore = ($offset + $pageSize) < $total; |
|
|
|
return [ |
|
'events' => $pageEvents, |
|
'hasMore' => $hasMore, |
|
'total' => $total, |
|
'page' => $page, |
|
'pageSize' => $pageSize, |
|
]; |
|
|
|
} catch (InvalidArgumentException $e) { |
|
$this->logger->error('Cache error getting paginated media events.', ['exception' => $e]); |
|
return [ |
|
'events' => [], |
|
'hasMore' => false, |
|
'total' => 0, |
|
'page' => $page, |
|
'pageSize' => $pageSize, |
|
]; |
|
} |
|
} |
|
|
|
/** |
|
* Get a single event by ID with caching |
|
* |
|
* @param string $eventId The event ID |
|
* @param array|null $relays Optional relays to query |
|
* @return object|null The event object or null if not found |
|
*/ |
|
public function getEvent(string $eventId, ?array $relays = null): ?object |
|
{ |
|
$cacheKey = 'event_' . $eventId . ($relays ? '_' . md5(json_encode($relays)) : ''); |
|
|
|
try { |
|
return $this->npubCache->get($cacheKey, function (ItemInterface $item) use ($eventId, $relays) { |
|
$item->expiresAfter(1800); // 30 minutes cache for events |
|
|
|
try { |
|
$event = $this->nostrClient->getEventById($eventId, $relays); |
|
return $event; |
|
} catch (\Exception $e) { |
|
$this->logger->error('Error getting event.', ['exception' => $e, 'eventId' => $eventId]); |
|
return null; |
|
} |
|
}); |
|
} catch (InvalidArgumentException $e) { |
|
$this->logger->error('Cache error getting event.', ['exception' => $e, 'eventId' => $eventId]); |
|
return null; |
|
} |
|
} |
|
|
|
public function setMetadata(\swentel\nostr\Event\Event $event): void |
|
{ |
|
$key = new Key(); |
|
$npub = $key->convertPublicKeyToBech32($event->getPublicKey()); |
|
$cacheKey = '0_' . $npub; |
|
try { |
|
$item = $this->npubCache->getItem($cacheKey); |
|
$item->set(json_decode($event->getContent())); |
|
$item->expiresAfter(3600); // 1 hour |
|
$this->npubCache->save($item); |
|
} catch (\Exception $e) { |
|
$this->logger->error('Error setting user metadata.', ['exception' => $e]); |
|
} |
|
} |
|
|
|
private function commentsKey(string $coordinate): string |
|
{ |
|
return 'comments_' . $coordinate; |
|
} |
|
|
|
/** Return cached comments payload or null */ |
|
public function getCommentsPayload(string $coordinate): ?array |
|
{ |
|
$key = $this->commentsKey($coordinate); |
|
try { |
|
$item = $this->npubCache->getItem($key); |
|
if ($item->isHit()) { |
|
$val = $item->get(); |
|
return is_array($val) ? $val : null; |
|
} |
|
} catch (\Throwable $e) { |
|
$this->logger->warning('Comments cache get failed', ['e' => $e->getMessage(), 'coord' => $coordinate]); |
|
} |
|
return null; |
|
} |
|
|
|
/** Save payload with TTL (seconds) */ |
|
public function setCommentsPayload(string $coordinate, array $payload, int $ttl): void |
|
{ |
|
$key = $this->commentsKey($coordinate); |
|
try { |
|
$item = $this->npubCache->getItem($key); |
|
$item->set($payload); |
|
$item->expiresAfter($ttl); |
|
$this->npubCache->save($item); |
|
} catch (\Throwable $e) { |
|
$this->logger->warning('Comments cache set failed', ['e' => $e->getMessage(), 'coord' => $coordinate]); |
|
} |
|
} |
|
|
|
|
|
}
|
|
|